İçeriğe geç

Communications & Credentials Audit Implementation Plan

Derin

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Admin paneline (1) müşteriye giden tüm mail/SMS iletilerinin tam içerikli forensic log’unu, ve (2) tüm provisionlanan portalların admin/customer/mail/API credential’larının portal-bazında ve tip-bazında görüntülenebildiği panel eklemek.

Architecture: sendMail/sendSms içine inline DB log yazımı + AES-256-GCM şifreli payload. Ayrı EmailLog/SmsLog tabloları, MailPayload/SmsPayload cipher tabloları. Admin UI Next.js App Router altında /communications ve /credentials route gruplarında, mevcut (panel) layout’unun parçası. Reveal/copy/view aksiyonları AdminAuditLog’a yazılır.

Tech Stack: Next.js 16, React 19, Prisma 7, PostgreSQL 18, nodemailer 8, twilio 6, node:test (built-in test runner, Node 22+), tsx loader (mevcut devDependency).

Spec: docs/superpowers/specs/2026-05-03-communications-credentials-audit-design.md


Core lib:

  • lib/crypto/payload-cipher.ts — AES-256-GCM encrypt/decrypt (ayrı key)
  • lib/crypto/payload-cipher.test.ts — round-trip + tamper test
  • lib/audit-actions.ts — yeni action constants + dedup helper
  • lib/audit-actions.test.ts — dedup logic test

Prisma:

  • prisma/migrations/<timestamp>_add_communications_logs/migration.sql
  • prisma/schema.prisma — UPDATE (4 yeni model)

API endpoints — communications:

  • app/api/admin/communications/emails/route.ts
  • app/api/admin/communications/emails/[id]/route.ts
  • app/api/admin/communications/emails/[id]/preview/route.ts
  • app/api/admin/communications/emails/[id]/payload/route.ts
  • app/api/admin/communications/emails/[id]/resend/route.ts
  • app/api/admin/communications/emails/export/route.ts
  • app/api/admin/communications/sms/route.ts
  • app/api/admin/communications/sms/[id]/route.ts
  • app/api/admin/communications/sms/[id]/payload/route.ts
  • app/api/admin/communications/sms/[id]/resend/route.ts
  • app/api/admin/communications/sms/export/route.ts

API endpoints — credentials:

  • app/api/admin/credentials/portals/route.ts
  • app/api/admin/credentials/portals/[subdomain]/route.ts
  • app/api/admin/credentials/by-type/admins/route.ts
  • app/api/admin/credentials/by-type/customers/route.ts
  • app/api/admin/credentials/by-type/mail/route.ts
  • app/api/admin/credentials/by-type/api/route.ts
  • app/api/admin/credentials/reveal/route.ts

Cron:

  • app/api/cron/log-retention/route.ts

Admin UI — communications:

  • app/[locale]/admin/(panel)/communications/layout.tsx
  • app/[locale]/admin/(panel)/communications/page.tsx
  • app/[locale]/admin/(panel)/communications/emails/page.tsx
  • app/[locale]/admin/(panel)/communications/emails/[id]/page.tsx
  • app/[locale]/admin/(panel)/communications/sms/page.tsx
  • app/[locale]/admin/(panel)/communications/sms/[id]/page.tsx
  • components/admin/communications/EmailLogTable.tsx
  • components/admin/communications/EmailLogFilters.tsx
  • components/admin/communications/EmailDetailDrawer.tsx
  • components/admin/communications/EmailRenderedTab.tsx
  • components/admin/communications/ResendDialog.tsx
  • components/admin/communications/SmsLogTable.tsx
  • components/admin/communications/SmsDetailDrawer.tsx
  • components/admin/communications/CommunicationsTabs.tsx

Admin UI — credentials:

  • app/[locale]/admin/(panel)/credentials/layout.tsx
  • app/[locale]/admin/(panel)/credentials/page.tsx
  • app/[locale]/admin/(panel)/credentials/by-portal/page.tsx
  • app/[locale]/admin/(panel)/credentials/by-portal/[subdomain]/page.tsx
  • app/[locale]/admin/(panel)/credentials/by-type/admins/page.tsx
  • app/[locale]/admin/(panel)/credentials/by-type/customers/page.tsx
  • app/[locale]/admin/(panel)/credentials/by-type/mail/page.tsx
  • app/[locale]/admin/(panel)/credentials/by-type/api/page.tsx
  • components/admin/credentials/CredentialsTabs.tsx
  • components/admin/credentials/PortalListTable.tsx
  • components/admin/credentials/PortalDetailHeader.tsx
  • components/admin/credentials/CredentialBlock.tsx
  • components/admin/credentials/RevealField.tsx
  • components/admin/credentials/TypeListTable.tsx
  • prisma/schema.prisma — 4 yeni model
  • lib/mail/service.ts — log yazımı + context parametresi
  • lib/mail/queue.tsQueuedEmail.emailLogId field, success/fail update
  • lib/sms/service.ts — log yazımı + context parametresi
  • package.jsontest scripts
  • .env.exampleMAIL_PAYLOAD_KEY, LOG_RETENTION_DAYS
  • components/admin/AdminSidebar.tsx — yeni nav item’lar (Communications, Credentials)

Files:

  • Modify: package.json

  • Step 1: package.json scripts’ine test komutu ekle

package.json scripts bloğunda mevcut lint:fix satırından sonra şu satırları ekle:

"lint:fix": "eslint . --fix",
"test": "node --import tsx --test \"lib/**/*.test.ts\" \"app/**/*.test.ts\"",
"test:watch": "node --import tsx --test --watch \"lib/**/*.test.ts\" \"app/**/*.test.ts\"",
"translate": "npx tsx scripts/smart-translate.ts",
  • Step 2: Smoke test — runner çalıştığını doğrula

Geçici dosya lib/_smoke.test.ts oluştur:

import { test } from 'node:test';
import { strict as assert } from 'node:assert';
test('runner works', () => {
assert.equal(1 + 1, 2);
});

Çalıştır:

Terminal window
npm test

Beklenen: # pass 1 çıktısı, exit code 0.

  • Step 3: Smoke dosyasını sil
Terminal window
rm lib/_smoke.test.ts
  • Step 4: Commit
Terminal window
git add package.json
git commit -m "chore(test): node:test runner script ekle"

Files:

  • Create: lib/crypto/payload-cipher.test.ts

  • Step 1: Test dosyasını yaz

lib/crypto/payload-cipher.test.ts
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
process.env.MAIL_PAYLOAD_KEY = 'a'.repeat(64); // 32-byte hex
const { encryptPayload, decryptPayload } = await import('./payload-cipher.ts');
test('round-trip preserves plaintext', () => {
const plaintext = JSON.stringify({ name: 'Ali', token: 'abc123' });
const enc = encryptPayload(plaintext);
assert.ok(Buffer.isBuffer(enc.ciphertext));
assert.ok(Buffer.isBuffer(enc.iv));
assert.ok(Buffer.isBuffer(enc.authTag));
assert.equal(enc.iv.length, 12);
assert.equal(enc.authTag.length, 16);
const dec = decryptPayload(enc);
assert.equal(dec, plaintext);
});
test('different IVs produce different ciphertexts', () => {
const plaintext = 'hello';
const a = encryptPayload(plaintext);
const b = encryptPayload(plaintext);
assert.notEqual(a.iv.toString('hex'), b.iv.toString('hex'));
assert.notEqual(a.ciphertext.toString('hex'), b.ciphertext.toString('hex'));
});
test('tampered ciphertext fails decrypt', () => {
const enc = encryptPayload('secret');
const tampered = { ...enc, ciphertext: Buffer.from(enc.ciphertext) };
tampered.ciphertext[0] ^= 0xff;
assert.throws(() => decryptPayload(tampered), /unable to authenticate/i);
});
test('missing key throws', async () => {
delete process.env.MAIL_PAYLOAD_KEY;
// re-import dynamically with cleared cache
const fresh = await import('./payload-cipher.ts?nocache=' + Date.now());
assert.throws(() => fresh.encryptPayload('x'), /MAIL_PAYLOAD_KEY/);
process.env.MAIL_PAYLOAD_KEY = 'a'.repeat(64);
});
  • Step 2: Test’i çalıştır, fail beklendiğini doğrula
Terminal window
npm test -- "lib/crypto/payload-cipher.test.ts"

Beklenen: Cannot find module './payload-cipher.ts'

Files:

  • Create: lib/crypto/payload-cipher.ts

  • Step 1: Implementation yaz

lib/crypto/payload-cipher.ts
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12; // GCM önerisi
const KEY_LENGTH = 32; // 256-bit
export interface EncryptedPayload {
ciphertext: Buffer;
iv: Buffer;
authTag: Buffer;
}
function getKey(): Buffer {
const hex = process.env.MAIL_PAYLOAD_KEY;
if (!hex) {
throw new Error('MAIL_PAYLOAD_KEY is required (32-byte hex). Generate: openssl rand -hex 32');
}
const buf = Buffer.from(hex, 'hex');
if (buf.length !== KEY_LENGTH) {
throw new Error(`MAIL_PAYLOAD_KEY must decode to ${KEY_LENGTH} bytes, got ${buf.length}`);
}
return buf;
}
export function encryptPayload(plaintext: string): EncryptedPayload {
const key = getKey();
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
return { ciphertext, iv, authTag };
}
export function decryptPayload(input: EncryptedPayload): string {
const key = getKey();
const decipher = createDecipheriv(ALGORITHM, key, input.iv);
decipher.setAuthTag(input.authTag);
const plaintext = Buffer.concat([decipher.update(input.ciphertext), decipher.final()]);
return plaintext.toString('utf8');
}
  • Step 2: Test’i çalıştır, pass beklendiğini doğrula
Terminal window
npm test -- "lib/crypto/payload-cipher.test.ts"

Beklenen: 4 pass.

  • Step 3: .env.example güncelle

.env.example dosyasında VAULT_ENCRYPTION_KEY satırını bul ve hemen altına ekle:

# Mail/SMS forensic log payload encryption (AES-256-GCM, 32-byte hex)
# Generate: openssl rand -hex 32
MAIL_PAYLOAD_KEY=
# Communications log retention in days (default 90)
LOG_RETENTION_DAYS=90
  • Step 4: Local .env’e geçici key oluştur
Terminal window
echo "MAIL_PAYLOAD_KEY=$(openssl rand -hex 32)" >> .env
echo "LOG_RETENTION_DAYS=90" >> .env

(Production deploy öncesi gerçek key set edilecek.)

  • Step 5: Commit
Terminal window
git add lib/crypto/payload-cipher.ts lib/crypto/payload-cipher.test.ts .env.example
git commit -m "feat(crypto): payload-cipher AES-256-GCM util ekle"

Files:

  • Modify: prisma/schema.prisma

  • Step 1: schema.prisma sonuna yeni modelleri ekle

prisma/schema.prisma dosyasının sonuna ekle:

// ─── Communications Forensic Log ───────────────────────────────
model EmailLog {
id Int @id @default(autoincrement())
template String
recipient String
subject String
html String @db.Text
text String @db.Text
headers Json
locale String?
status String @default("pending") // pending | sent | failed | retrying
messageId String?
errorMessage String?
attemptCount Int @default(1)
customerId Int?
adminId Int?
ipAddress String?
createdAt DateTime @default(now())
sentAt DateTime?
payload MailPayload?
@@index([template])
@@index([recipient])
@@index([customerId])
@@index([status])
@@index([createdAt])
}
model MailPayload {
emailLogId Int @id
ciphertext Bytes
iv Bytes
authTag Bytes
emailLog EmailLog @relation(fields: [emailLogId], references: [id], onDelete: Cascade)
}
model SmsLog {
id Int @id @default(autoincrement())
template String
recipient String
body String @db.Text
fromNumber String?
locale String?
status String @default("pending") // pending | sent | failed
twilioSid String?
errorMessage String?
customerId Int?
adminId Int?
ipAddress String?
createdAt DateTime @default(now())
sentAt DateTime?
payload SmsPayload?
@@index([template])
@@index([recipient])
@@index([customerId])
@@index([status])
@@index([createdAt])
}
model SmsPayload {
smsLogId Int @id
ciphertext Bytes
iv Bytes
authTag Bytes
smsLog SmsLog @relation(fields: [smsLogId], references: [id], onDelete: Cascade)
}
  • Step 2: Prisma format ve validate
Terminal window
npx prisma format
npx prisma validate

Beklenen: The schema is valid.

  • Step 3: Migration üret
Terminal window
npx prisma migrate dev --name add_communications_logs

Beklenen: prisma/migrations/<timestamp>_add_communications_logs/migration.sql oluşur, DB’ye uygulanır.

  • Step 4: Migration SQL’ini gözle kontrol et
Terminal window
ls -la prisma/migrations/ | tail -3
cat prisma/migrations/*_add_communications_logs/migration.sql | head -80

Bekleneni doğrula: CREATE TABLE "EmailLog", CREATE TABLE "MailPayload", FK constraints, index’ler.

  • Step 5: Prisma client regenerate
Terminal window
npx prisma generate
  • Step 6: Commit
Terminal window
git add prisma/schema.prisma prisma/migrations/
git commit -m "feat(db): EmailLog/SmsLog/MailPayload/SmsPayload modelleri ekle"

Files:

  • Create: lib/_db-smoke.test.ts (geçici)

  • Step 1: Smoke test yaz

lib/_db-smoke.test.ts
import { test, after } from 'node:test';
import { strict as assert } from 'node:assert';
import prisma from './prisma.ts';
test('EmailLog round-trip', async () => {
const log = await prisma.emailLog.create({
data: {
template: 'smoke-test',
recipient: 'smoke@example.com',
subject: 'smoke',
html: '<p>x</p>',
text: 'x',
headers: {},
status: 'pending',
},
});
assert.ok(log.id > 0);
const fetched = await prisma.emailLog.findUnique({ where: { id: log.id } });
assert.equal(fetched?.template, 'smoke-test');
await prisma.emailLog.delete({ where: { id: log.id } });
});
after(async () => {
await prisma.$disconnect();
});
  • Step 2: Çalıştır
Terminal window
npm test -- "lib/_db-smoke.test.ts"

Beklenen: 1 pass. Eğer fail ederse migration uygulanmamış demektir.

  • Step 3: Smoke test’i sil
Terminal window
rm lib/_db-smoke.test.ts
  • Step 4: Commit (boş — değişiklik yok, atla)

Task 2.1: sendMail context parametresi + log yazımı — failing test

Bölüm başlığı “Task 2.1: sendMail context parametresi + log yazımı — failing test”

Files:

  • Create: lib/mail/service.test.ts

  • Step 1: Test dosyasını yaz

lib/mail/service.test.ts
import { test, before, after } from 'node:test';
import { strict as assert } from 'node:assert';
process.env.MAIL_PAYLOAD_KEY ??= 'a'.repeat(64);
// Transporter mock — gerçek SMTP bağlantısı yapmasın diye env override
process.env.SMTP_HOST = 'mock';
process.env.SMTP_PORT = '0';
import prisma from '../prisma.ts';
before(async () => {
// mock için transport modülünü override
const transport = await import('./transport.ts');
(transport as any).getTransporter = () => ({
sendMail: async () => ({ messageId: '<mock-msg-id@test>' }),
});
});
after(async () => {
await prisma.emailLog.deleteMany({ where: { template: 'magic-link', recipient: 'test-mail-svc@example.com' } });
await prisma.$disconnect();
});
test('sendMail logs success to EmailLog', async () => {
const { sendMail } = await import('./service.ts');
const result = await sendMail({
to: 'test-mail-svc@example.com',
template: 'magic-link',
data: { name: 'Test', magicLink: 'https://x', ipAddress: '1.2.3.4', locale: 'en' },
context: { ipAddress: '1.2.3.4' },
});
assert.equal(result.success, true);
const log = await prisma.emailLog.findFirst({
where: { recipient: 'test-mail-svc@example.com', template: 'magic-link' },
orderBy: { id: 'desc' },
});
assert.ok(log, 'EmailLog satırı oluşmadı');
assert.equal(log!.status, 'sent');
assert.equal(log!.messageId, '<mock-msg-id@test>');
assert.ok(log!.html.length > 0);
assert.equal(log!.ipAddress, '1.2.3.4');
});
test('sendMail logs encrypted payload', async () => {
const { sendMail } = await import('./service.ts');
await sendMail({
to: 'test-mail-svc@example.com',
template: 'magic-link',
data: { name: 'Payload', magicLink: 'https://y', ipAddress: '1.1.1.1', locale: 'en' },
});
const log = await prisma.emailLog.findFirst({
where: { recipient: 'test-mail-svc@example.com' },
orderBy: { id: 'desc' },
include: { payload: true },
});
assert.ok(log?.payload, 'MailPayload yok');
assert.ok(log!.payload!.ciphertext.length > 0);
});
  • Step 2: Test çalıştır, fail beklendiğini doğrula
Terminal window
npm test -- "lib/mail/service.test.ts"

Beklenen: context parametresi tanımsız, ya Property 'context' does not exist ya da log oluşmadığı için assertion fail.

Files:

  • Modify: lib/mail/service.ts

  • Step 1: service.ts’i güncelle

lib/mail/service.ts dosyasını şu içerikle değiştir:

import { getTransporter } from './transport';
import { renderTemplate, type TemplateName } from './templates';
import { enqueueEmail } from './queue';
import { generateUnsubscribeToken } from './unsubscribe';
import prisma from '@/lib/prisma';
import { encryptPayload } from '@/lib/crypto/payload-cipher';
type TemplatePropsMap = {
// ... (mevcut tüm template tanımlamaları aynen kalsın)
// BU BLOK MEVCUT DOSYADAN OLDUĞU GİBİ KOPYALANIR — değiştirme
};
interface SendMailResult {
success: boolean;
messageId?: string;
error?: string;
emailLogId?: number;
}
export type { TemplatePropsMap };
export async function getAdminEmails(): Promise<string[]> {
// mevcut implementation — değişmiyor
}
const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? 'https://ecutuningportal.com';
function buildUnsubscribeHeaders(to: string | string[]): Record<string, string> {
if (Array.isArray(to)) return {};
const token = generateUnsubscribeToken(to);
const encoded = encodeURIComponent(to);
return {
'List-Unsubscribe': `<${APP_URL}/api/unsubscribe?token=${token}&email=${encoded}>`,
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
};
}
interface SendContext {
customerId?: number;
adminId?: number;
ipAddress?: string;
}
export async function sendMail<T extends keyof TemplatePropsMap>(options: {
to: string | string[];
template: T;
data: TemplatePropsMap[T];
context?: SendContext;
}): Promise<SendMailResult> {
let html: string;
let text: string;
let subject: string;
try {
({ html, text, subject } = await renderTemplate(options.template, options.data));
} catch (renderError) {
const message = renderError instanceof Error ? renderError.message : 'Unknown render error';
console.error(`[Mail] Template render failed for "${options.template}":`, message);
return { success: false, error: message };
}
const domain = 'ecutuningportal.com';
const unsubscribeHeaders = buildUnsubscribeHeaders(options.to);
const recipient = Array.isArray(options.to) ? options.to.join(',') : options.to;
const headers: Record<string, string> = {
'Message-ID': `<${Date.now()}.${Math.random().toString(36).slice(2)}@${domain}>`,
'X-Auto-Response-Suppress': 'All',
'Auto-Submitted': 'auto-generated',
'Feedback-ID': `${options.template}:ecutuningportal:transactional`,
...unsubscribeHeaders,
};
// 1. Pre-send EmailLog
let log;
try {
log = await prisma.emailLog.create({
data: {
template: options.template as string,
recipient,
subject,
html,
text,
headers,
locale: (options.data as { locale?: string }).locale ?? null,
status: 'pending',
customerId: options.context?.customerId ?? null,
adminId: options.context?.adminId ?? null,
ipAddress: options.context?.ipAddress ?? null,
},
});
} catch (logErr) {
const msg = logErr instanceof Error ? logErr.message : 'Unknown log error';
console.error(`[Mail] EmailLog write failed for "${options.template}":`, msg);
return { success: false, error: `log_write_failed: ${msg}` };
}
// 2. Encrypt + store payload (best-effort, non-blocking)
try {
const enc = encryptPayload(JSON.stringify(options.data));
await prisma.mailPayload.create({
data: { emailLogId: log.id, ciphertext: enc.ciphertext, iv: enc.iv, authTag: enc.authTag },
});
} catch (payloadErr) {
const msg = payloadErr instanceof Error ? payloadErr.message : 'Unknown payload error';
console.error(`[Mail] Payload encrypt/store failed for log ${log.id}:`, msg);
await prisma.emailLog.update({
where: { id: log.id },
data: { errorMessage: `payload_encrypt_failed: ${msg}` },
});
// continue — transport step will still run
}
// 3. Transport
try {
const transporter = getTransporter();
const info = await transporter.sendMail({
from: process.env.SMTP_FROM,
to: options.to,
subject,
html,
text,
headers,
});
await prisma.emailLog.update({
where: { id: log.id },
data: { status: 'sent', messageId: info.messageId, sentAt: new Date() },
});
console.log(`[Mail] Sent "${options.template}" to ${recipient} — messageId: ${info.messageId} — logId: ${log.id}`);
return { success: true, messageId: info.messageId, emailLogId: log.id };
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error(`[Mail] Failed to send "${options.template}" to ${recipient}: ${message} — enqueueing for retry`);
await prisma.emailLog.update({
where: { id: log.id },
data: { status: 'retrying', errorMessage: message },
});
await enqueueEmail({
to: options.to,
subject,
html,
text,
headers: unsubscribeHeaders,
attempt: 1,
maxAttempts: 3,
createdAt: Date.now(),
emailLogId: log.id,
});
return { success: false, error: message, emailLogId: log.id };
}
}

TemplatePropsMap ve getAdminEmails mevcut dosyadan birebir kopyalanır — yukarıdaki snippet kısaltma için placeholder. Engineer mevcut dosyayı okuyup yalnızca değişen bölümleri yamalayabilir.

  • Step 2: Test’i çalıştır
Terminal window
npm test -- "lib/mail/service.test.ts"

Beklenen: 2 pass.

  • Step 3: Commit
Terminal window
git add lib/mail/service.ts lib/mail/service.test.ts
git commit -m "feat(mail): sendMail içine EmailLog + MailPayload yazımı"

Files:

  • Modify: lib/mail/queue.ts

  • Step 1: QueuedEmail interface’ini genişlet ve trySend’i güncelle

lib/mail/queue.ts dosyasında:

QueuedEmail interface’ine emailLogId?: number; ekle:

export interface QueuedEmail {
to: string | string[];
subject: string;
html: string;
text: string;
headers?: Record<string, string>;
attempt: number;
maxAttempts: number;
createdAt: number;
/** ID of the EmailLog row that tracks this send attempt's status. */
emailLogId?: number;
}

trySend fonksiyonunun başına import prisma from '@/lib/prisma'; ekle (dosya üstünde) ve trySend içine başarı/hata sonrası DB update yaz:

async function trySend(email: QueuedEmail): Promise<boolean> {
const transporter: nodemailer.Transporter = getTransporter();
const domain = 'ecutuningportal.com';
try {
await transporter.sendMail({
from: process.env.SMTP_FROM,
to: email.to,
subject: email.subject,
html: email.html,
text: email.text,
headers: {
'Message-ID': `<${Date.now()}.${Math.random().toString(36).slice(2)}@${domain}>`,
'X-Auto-Response-Suppress': 'All',
'Auto-Submitted': 'auto-generated',
...(email.headers ?? {}),
},
});
if (email.emailLogId) {
await prisma.emailLog.update({
where: { id: email.emailLogId },
data: {
status: 'sent',
attemptCount: email.attempt,
sentAt: new Date(),
errorMessage: null,
},
}).catch(err => console.error('[EmailQueue] log update on success failed:', err));
}
const recipients = Array.isArray(email.to) ? email.to.join(', ') : email.to;
console.log(`[EmailQueue] Successfully sent queued email to ${recipients} (logId: ${email.emailLogId ?? 'none'})`);
return true;
} catch (err) {
if (email.emailLogId) {
const msg = err instanceof Error ? err.message : 'Unknown error';
const finalAttempt = email.attempt + 1 >= (email.maxAttempts ?? MAX_ATTEMPTS);
await prisma.emailLog.update({
where: { id: email.emailLogId },
data: {
status: finalAttempt ? 'failed' : 'retrying',
attemptCount: email.attempt,
errorMessage: msg,
},
}).catch(updateErr => console.error('[EmailQueue] log update on failure failed:', updateErr));
}
console.error('[EmailQueue] Send failed:', err);
return false;
}
}
  • Step 2: Manuel doğrulama — type-check
Terminal window
npx tsc --noEmit

Beklenen: hata yok.

  • Step 3: Commit
Terminal window
git add lib/mail/queue.ts
git commit -m "feat(mail): EmailQueue emailLogId taşıma + status sync"

Files:

  • Create: lib/sms/service.test.ts

  • Step 1: Test yaz

lib/sms/service.test.ts
import { test, before, after } from 'node:test';
import { strict as assert } from 'node:assert';
process.env.MAIL_PAYLOAD_KEY ??= 'a'.repeat(64);
process.env.TWILIO_PHONE_NUMBER = '+15555550100';
import prisma from '../prisma.ts';
before(async () => {
const client = await import('./client.ts');
(client as any).getTwilioClient = () => ({
messages: { create: async () => ({ sid: 'SMmocksid12345' }) },
});
});
after(async () => {
await prisma.smsLog.deleteMany({ where: { recipient: '+905551234999' } });
await prisma.$disconnect();
});
test('sendSms logs to SmsLog with payload', async () => {
const { sendSms } = await import('./service.ts');
const result = await sendSms({
to: '+905551234999',
template: 'otp',
data: { code: '123456', minutes: '5' },
locale: 'tr',
context: { ipAddress: '9.9.9.9' },
});
assert.equal(result.success, true);
assert.equal(result.sid, 'SMmocksid12345');
const log = await prisma.smsLog.findFirst({
where: { recipient: '+905551234999' },
include: { payload: true },
});
assert.ok(log);
assert.equal(log!.status, 'sent');
assert.equal(log!.twilioSid, 'SMmocksid12345');
assert.ok(log!.body.includes('123456'));
assert.ok(log!.payload);
});
  • Step 2: Fail beklendiğini doğrula
Terminal window
npm test -- "lib/sms/service.test.ts"

Files:

  • Modify: lib/sms/service.ts

  • Step 1: service.ts’i güncelle

lib/sms/service.ts
import { getTwilioClient } from './client';
import { getSmsText, type SmsTemplate } from './i18n';
import prisma from '@/lib/prisma';
import { encryptPayload } from '@/lib/crypto/payload-cipher';
interface SendContext {
customerId?: number;
adminId?: number;
ipAddress?: string;
}
interface SendSmsOptions {
to: string;
template: SmsTemplate;
data: Record<string, string>;
locale?: string;
context?: SendContext;
}
interface SendSmsResult {
success: boolean;
sid?: string;
error?: string;
smsLogId?: number;
}
export async function sendSms(options: SendSmsOptions): Promise<SendSmsResult> {
const body = getSmsText(options.template, options.locale ?? 'en', options.data);
const fromNumber = process.env.TWILIO_PHONE_NUMBER ?? null;
let log;
try {
log = await prisma.smsLog.create({
data: {
template: options.template,
recipient: options.to,
body,
fromNumber,
locale: options.locale ?? null,
status: 'pending',
customerId: options.context?.customerId ?? null,
adminId: options.context?.adminId ?? null,
ipAddress: options.context?.ipAddress ?? null,
},
});
} catch (logErr) {
const msg = logErr instanceof Error ? logErr.message : 'Unknown log error';
console.error(`[SMS] SmsLog write failed for "${options.template}":`, msg);
return { success: false, error: `log_write_failed: ${msg}` };
}
try {
const enc = encryptPayload(JSON.stringify(options.data));
await prisma.smsPayload.create({
data: { smsLogId: log.id, ciphertext: enc.ciphertext, iv: enc.iv, authTag: enc.authTag },
});
} catch (payloadErr) {
const msg = payloadErr instanceof Error ? payloadErr.message : 'Unknown payload error';
console.error(`[SMS] Payload encrypt/store failed for log ${log.id}:`, msg);
await prisma.smsLog.update({
where: { id: log.id },
data: { errorMessage: `payload_encrypt_failed: ${msg}` },
});
}
try {
const client = getTwilioClient();
const message = await client.messages.create({
body,
from: process.env.TWILIO_PHONE_NUMBER,
to: options.to,
});
await prisma.smsLog.update({
where: { id: log.id },
data: { status: 'sent', twilioSid: message.sid, sentAt: new Date() },
});
console.log(`[SMS] Sent "${options.template}" to ${options.to} — sid: ${message.sid} — logId: ${log.id}`);
return { success: true, sid: message.sid, smsLogId: log.id };
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error(`[SMS] Failed to send "${options.template}" to ${options.to}:`, message);
await prisma.smsLog.update({
where: { id: log.id },
data: { status: 'failed', errorMessage: message },
});
return { success: false, error: message, smsLogId: log.id };
}
}
  • Step 2: Test’i çalıştır
Terminal window
npm test -- "lib/sms/service.test.ts"

Beklenen: 1 pass.

  • Step 3: Commit
Terminal window
git add lib/sms/service.ts lib/sms/service.test.ts
git commit -m "feat(sms): sendSms içine SmsLog + SmsPayload yazımı"

Files:

  • Create: lib/audit-actions.test.ts

  • Step 1: Test yaz

lib/audit-actions.test.ts
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { AUDIT_ACTIONS, shouldLogListView, resetListViewCache } from './audit-actions.ts';
test('action constants exist', () => {
assert.equal(AUDIT_ACTIONS.EMAIL_LIST_VIEW, 'communications.email.list_view');
assert.equal(AUDIT_ACTIONS.CREDENTIAL_REVEAL, 'credential.reveal');
});
test('shouldLogListView dedupes within same admin/action/day', () => {
resetListViewCache();
assert.equal(shouldLogListView(1, 'communications.email.list_view'), true);
assert.equal(shouldLogListView(1, 'communications.email.list_view'), false);
assert.equal(shouldLogListView(2, 'communications.email.list_view'), true);
assert.equal(shouldLogListView(1, 'credential.portal_list_view'), true);
});
  • Step 2: Fail beklendiğini doğrula
Terminal window
npm test -- "lib/audit-actions.test.ts"

Files:

  • Create: lib/audit-actions.ts

  • Step 1: Implementation yaz

lib/audit-actions.ts
export const AUDIT_ACTIONS = {
// Communications — emails
EMAIL_LIST_VIEW: 'communications.email.list_view',
EMAIL_DETAIL_VIEW: 'communications.email.detail_view',
EMAIL_BODY_VIEW: 'communications.email.body_view',
EMAIL_PAYLOAD_REVEAL: 'communications.email.payload_reveal',
EMAIL_RESEND: 'communications.email.resend',
EMAIL_DELETE: 'communications.email.delete',
EMAIL_EXPORT: 'communications.email.export',
// Communications — sms
SMS_LIST_VIEW: 'communications.sms.list_view',
SMS_DETAIL_VIEW: 'communications.sms.detail_view',
SMS_BODY_VIEW: 'communications.sms.body_view',
SMS_PAYLOAD_REVEAL: 'communications.sms.payload_reveal',
SMS_RESEND: 'communications.sms.resend',
SMS_DELETE: 'communications.sms.delete',
SMS_EXPORT: 'communications.sms.export',
// Credentials
CREDENTIAL_PORTAL_LIST_VIEW: 'credential.portal_list_view',
CREDENTIAL_PORTAL_DETAIL_VIEW: 'credential.portal_detail_view',
CREDENTIAL_TYPE_LIST_VIEW: 'credential.type_list_view',
CREDENTIAL_REVEAL: 'credential.reveal',
CREDENTIAL_COPY: 'credential.copy',
} as const;
export type AuditAction = (typeof AUDIT_ACTIONS)[keyof typeof AUDIT_ACTIONS];
const LIST_VIEW_ACTIONS: ReadonlySet<string> = new Set([
AUDIT_ACTIONS.EMAIL_LIST_VIEW,
AUDIT_ACTIONS.SMS_LIST_VIEW,
AUDIT_ACTIONS.CREDENTIAL_PORTAL_LIST_VIEW,
AUDIT_ACTIONS.CREDENTIAL_TYPE_LIST_VIEW,
]);
// In-memory dedup cache: key = "<adminId>:<action>:<YYYY-MM-DD>"
const listViewCache = new Map<string, number>();
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
function todayKey(adminId: number, action: string): string {
const d = new Date();
return `${adminId}:${action}:${d.getUTCFullYear()}-${d.getUTCMonth()}-${d.getUTCDate()}`;
}
export function shouldLogListView(adminId: number, action: string): boolean {
if (!LIST_VIEW_ACTIONS.has(action)) return true;
const key = todayKey(adminId, action);
const now = Date.now();
const last = listViewCache.get(key);
if (last && now - last < ONE_DAY_MS) return false;
listViewCache.set(key, now);
// basit GC — 1000 üzerinde temizle
if (listViewCache.size > 1000) {
for (const [k, t] of listViewCache.entries()) {
if (now - t > ONE_DAY_MS) listViewCache.delete(k);
}
}
return true;
}
export function resetListViewCache(): void {
listViewCache.clear();
}
  • Step 2: Test’i çalıştır, pass beklendiğini doğrula
Terminal window
npm test -- "lib/audit-actions.test.ts"
  • Step 3: Commit
Terminal window
git add lib/audit-actions.ts lib/audit-actions.test.ts
git commit -m "feat(audit): communications/credentials audit action constants + dedup"

Task 5.1: GET /admin/communications/emails — list endpoint

Bölüm başlığı “Task 5.1: GET /admin/communications/emails — list endpoint”

Files:

  • Create: app/api/admin/communications/emails/route.ts

  • Step 1: Implementation

app/api/admin/communications/emails/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getAdminSession } from '@/lib/admin-session';
import prisma from '@/lib/prisma';
import { auditLog, getRequestMeta } from '@/lib/audit-log';
import { AUDIT_ACTIONS, shouldLogListView } from '@/lib/audit-actions';
import type { Prisma } from '@/generated/prisma';
const PAGE_SIZE = 25;
export async function GET(req: NextRequest) {
const session = await getAdminSession();
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const sp = req.nextUrl.searchParams;
const cursor = sp.get('cursor');
const template = sp.get('template');
const status = sp.get('status');
const locale = sp.get('locale');
const recipient = sp.get('recipient');
const dateFrom = sp.get('dateFrom');
const dateTo = sp.get('dateTo');
const search = sp.get('search');
const where: Prisma.EmailLogWhereInput = {};
if (template) where.template = template;
if (status) where.status = status;
if (locale) where.locale = locale;
if (recipient) where.recipient = { contains: recipient, mode: 'insensitive' };
if (dateFrom || dateTo) {
where.createdAt = {};
if (dateFrom) where.createdAt.gte = new Date(dateFrom);
if (dateTo) where.createdAt.lte = new Date(dateTo);
}
if (search) {
where.OR = [
{ subject: { contains: search, mode: 'insensitive' } },
{ text: { contains: search, mode: 'insensitive' } },
];
}
const items = await prisma.emailLog.findMany({
where,
orderBy: { id: 'desc' },
take: PAGE_SIZE + 1,
cursor: cursor ? { id: parseInt(cursor, 10) } : undefined,
skip: cursor ? 1 : 0,
select: {
id: true, template: true, recipient: true, subject: true,
status: true, locale: true, customerId: true, messageId: true,
errorMessage: true, attemptCount: true, createdAt: true, sentAt: true,
},
});
const total = await prisma.emailLog.count({ where });
const hasMore = items.length > PAGE_SIZE;
const result = hasMore ? items.slice(0, PAGE_SIZE) : items;
const nextCursor = hasMore ? String(result[result.length - 1].id) : null;
if (shouldLogListView(session.adminId, AUDIT_ACTIONS.EMAIL_LIST_VIEW)) {
const meta = await getRequestMeta();
auditLog({
adminId: session.adminId,
action: AUDIT_ACTIONS.EMAIL_LIST_VIEW,
ipAddress: meta.ip,
userAgent: meta.ua,
});
}
return NextResponse.json({ items: result, total, nextCursor });
}

Note: getAdminSession mevcut değilse engineer önce app/api/admin/auth/... rotalarındaki mevcut session helper’ı bulup adapt etmeli; bu plan o helper’ın varlığını varsayıyor. Uygulama anında benzer pattern’lar app/api/admin/users/route.ts içinde mevcut.

  • Step 2: Manuel curl testi

Geliştirme sunucusunu başlat:

Terminal window
npm run dev

Admin oturumu cookie’siyle test et:

Terminal window
curl -s "http://49.12.188.137:3077/api/admin/communications/emails?template=magic-link" \
-H "Cookie: $(cat /tmp/admin-cookie.txt)" | head -50

Beklenen: JSON { items: [...], total, nextCursor }.

  • Step 3: Commit
Terminal window
git add app/api/admin/communications/emails/route.ts
git commit -m "feat(api): GET /admin/communications/emails liste endpoint"

Task 5.2: GET /admin/communications/emails/[id] — detail + DELETE

Bölüm başlığı “Task 5.2: GET /admin/communications/emails/[id] — detail + DELETE”

Files:

  • Create: app/api/admin/communications/emails/[id]/route.ts

  • Step 1: Implementation

app/api/admin/communications/emails/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getAdminSession } from '@/lib/admin-session';
import prisma from '@/lib/prisma';
import { auditLog, getRequestMeta } from '@/lib/audit-log';
import { AUDIT_ACTIONS } from '@/lib/audit-actions';
interface RouteCtx { params: Promise<{ id: string }>; }
export async function GET(_req: NextRequest, ctx: RouteCtx) {
const session = await getAdminSession();
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { id } = await ctx.params;
const numId = parseInt(id, 10);
if (!Number.isFinite(numId)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
const log = await prisma.emailLog.findUnique({
where: { id: numId },
include: {
payload: { select: { emailLogId: true } }, // existence only, no decrypt
},
});
if (!log) return NextResponse.json({ error: 'Not found' }, { status: 404 });
const meta = await getRequestMeta();
auditLog({
adminId: session.adminId,
action: AUDIT_ACTIONS.EMAIL_DETAIL_VIEW,
target: `emailLog:${numId}`,
ipAddress: meta.ip,
userAgent: meta.ua,
});
return NextResponse.json({ log: { ...log, hasPayload: !!log.payload } });
}
export async function DELETE(_req: NextRequest, ctx: RouteCtx) {
const session = await getAdminSession();
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { id } = await ctx.params;
const numId = parseInt(id, 10);
if (!Number.isFinite(numId)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
await prisma.emailLog.delete({ where: { id: numId } }); // payload cascade
const meta = await getRequestMeta();
auditLog({
adminId: session.adminId,
action: AUDIT_ACTIONS.EMAIL_DELETE,
target: `emailLog:${numId}`,
ipAddress: meta.ip,
userAgent: meta.ua,
});
return NextResponse.json({ ok: true });
}
  • Step 2: Manuel test
Terminal window
curl -s "http://49.12.188.137:3077/api/admin/communications/emails/1" -H "Cookie: ..." | jq
  • Step 3: Commit
Terminal window
git add app/api/admin/communications/emails/[id]/route.ts
git commit -m "feat(api): emails detail + delete endpoints"

Task 5.3: GET …/[id]/preview — sandboxed iframe srcdoc

Bölüm başlığı “Task 5.3: GET …/[id]/preview — sandboxed iframe srcdoc”

Files:

  • Create: app/api/admin/communications/emails/[id]/preview/route.ts

  • Step 1: Implementation

app/api/admin/communications/emails/[id]/preview/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getAdminSession } from '@/lib/admin-session';
import prisma from '@/lib/prisma';
import { auditLog, getRequestMeta } from '@/lib/audit-log';
import { AUDIT_ACTIONS } from '@/lib/audit-actions';
interface RouteCtx { params: Promise<{ id: string }>; }
export async function GET(_req: NextRequest, ctx: RouteCtx) {
const session = await getAdminSession();
if (!session) return new NextResponse('Unauthorized', { status: 401 });
const { id } = await ctx.params;
const numId = parseInt(id, 10);
if (!Number.isFinite(numId)) return new NextResponse('Invalid id', { status: 400 });
const log = await prisma.emailLog.findUnique({
where: { id: numId },
select: { html: true },
});
if (!log) return new NextResponse('Not found', { status: 404 });
const meta = await getRequestMeta();
auditLog({
adminId: session.adminId,
action: AUDIT_ACTIONS.EMAIL_BODY_VIEW,
target: `emailLog:${numId}`,
ipAddress: meta.ip,
userAgent: meta.ua,
});
return new NextResponse(log.html, {
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Content-Security-Policy': "default-src 'none'; img-src data: https:; style-src 'unsafe-inline'; font-src https: data:",
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'SAMEORIGIN',
},
});
}

Frontend tarafta iframe <iframe srcdoc={...}> yerine <iframe src="/api/admin/communications/emails/{id}/preview" sandbox=""> kullanacağız — server’dan CSP header’ı zorlanır.

  • Step 2: Commit
Terminal window
git add app/api/admin/communications/emails/[id]/preview/route.ts
git commit -m "feat(api): emails preview endpoint (CSP-locked HTML)"

Files:

  • Create: app/api/admin/communications/emails/[id]/payload/route.ts

  • Step 1: Implementation

app/api/admin/communications/emails/[id]/payload/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getAdminSession } from '@/lib/admin-session';
import prisma from '@/lib/prisma';
import { auditLog, getRequestMeta } from '@/lib/audit-log';
import { AUDIT_ACTIONS } from '@/lib/audit-actions';
import { decryptPayload } from '@/lib/crypto/payload-cipher';
interface RouteCtx { params: Promise<{ id: string }>; }
export async function POST(req: NextRequest, ctx: RouteCtx) {
const session = await getAdminSession();
if (!session) {
auditLog({ adminId: null, action: AUDIT_ACTIONS.EMAIL_PAYLOAD_REVEAL, status: 'blocked' });
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await ctx.params;
const numId = parseInt(id, 10);
if (!Number.isFinite(numId)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
const body = await req.json().catch(() => ({}));
const source = body.source === 'copy' ? 'copy' : 'view';
const payload = await prisma.mailPayload.findUnique({ where: { emailLogId: numId } });
if (!payload) return NextResponse.json({ error: 'No payload' }, { status: 404 });
let plaintext: string;
try {
plaintext = decryptPayload({
ciphertext: payload.ciphertext,
iv: payload.iv,
authTag: payload.authTag,
});
} catch {
const meta = await getRequestMeta();
auditLog({
adminId: session.adminId,
action: AUDIT_ACTIONS.EMAIL_PAYLOAD_REVEAL,
target: `emailLog:${numId}`,
details: 'decrypt_failed',
ipAddress: meta.ip,
userAgent: meta.ua,
status: 'failed',
});
return NextResponse.json({ error: 'Decryption failed' }, { status: 500 });
}
const meta = await getRequestMeta();
auditLog({
adminId: session.adminId,
action: AUDIT_ACTIONS.EMAIL_PAYLOAD_REVEAL,
target: `emailLog:${numId}`,
details: `source:${source}`,
ipAddress: meta.ip,
userAgent: meta.ua,
});
return NextResponse.json({ payload: JSON.parse(plaintext) });
}
  • Step 2: Commit
Terminal window
git add app/api/admin/communications/emails/[id]/payload/route.ts
git commit -m "feat(api): emails payload reveal (decrypt + audit)"

Files:

  • Create: app/api/admin/communications/emails/[id]/resend/route.ts

  • Step 1: Implementation

app/api/admin/communications/emails/[id]/resend/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getAdminSession } from '@/lib/admin-session';
import prisma from '@/lib/prisma';
import { auditLog, getRequestMeta } from '@/lib/audit-log';
import { AUDIT_ACTIONS } from '@/lib/audit-actions';
import { decryptPayload } from '@/lib/crypto/payload-cipher';
import { sendMail, type TemplatePropsMap } from '@/lib/mail/service';
interface RouteCtx { params: Promise<{ id: string }>; }
export async function POST(req: NextRequest, ctx: RouteCtx) {
const session = await getAdminSession();
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { id } = await ctx.params;
const numId = parseInt(id, 10);
const body = await req.json().catch(() => ({}));
const reason = typeof body.reason === 'string' ? body.reason.trim() : '';
if (reason.length < 3) return NextResponse.json({ error: 'Reason required (min 3 chars)' }, { status: 400 });
const original = await prisma.emailLog.findUnique({
where: { id: numId },
include: { payload: true },
});
if (!original) return NextResponse.json({ error: 'Not found' }, { status: 404 });
if (!original.payload) return NextResponse.json({ error: 'No payload — cannot reconstruct' }, { status: 400 });
let data: TemplatePropsMap[keyof TemplatePropsMap];
try {
const plaintext = decryptPayload({
ciphertext: original.payload.ciphertext,
iv: original.payload.iv,
authTag: original.payload.authTag,
});
data = JSON.parse(plaintext);
} catch {
return NextResponse.json({ error: 'Payload corrupt' }, { status: 500 });
}
const meta = await getRequestMeta();
const result = await sendMail({
to: original.recipient.split(',').map(s => s.trim()),
template: original.template as keyof TemplatePropsMap,
data,
context: { adminId: session.adminId, ipAddress: meta.ip },
});
auditLog({
adminId: session.adminId,
action: AUDIT_ACTIONS.EMAIL_RESEND,
target: `emailLog:${numId}->emailLog:${result.emailLogId ?? 'failed'}`,
details: `reason:${reason}`,
ipAddress: meta.ip,
userAgent: meta.ua,
status: result.success ? 'success' : 'failed',
});
return NextResponse.json({ success: result.success, newEmailLogId: result.emailLogId, error: result.error });
}
  • Step 2: Commit
Terminal window
git add app/api/admin/communications/emails/[id]/resend/route.ts
git commit -m "feat(api): emails resend endpoint (zorunlu reason + audit chain)"

Files:

  • Create: app/api/admin/communications/emails/export/route.ts

  • Step 1: Implementation

app/api/admin/communications/emails/export/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getAdminSession } from '@/lib/admin-session';
import prisma from '@/lib/prisma';
import { auditLog, getRequestMeta } from '@/lib/audit-log';
import { AUDIT_ACTIONS } from '@/lib/audit-actions';
function csvEscape(v: unknown): string {
if (v === null || v === undefined) return '';
const s = String(v).replace(/"/g, '""');
return `"${s}"`;
}
export async function GET(req: NextRequest) {
const session = await getAdminSession();
if (!session) return new NextResponse('Unauthorized', { status: 401 });
const sp = req.nextUrl.searchParams;
const where: any = {};
if (sp.get('template')) where.template = sp.get('template');
if (sp.get('status')) where.status = sp.get('status');
if (sp.get('dateFrom') || sp.get('dateTo')) {
where.createdAt = {};
if (sp.get('dateFrom')) where.createdAt.gte = new Date(sp.get('dateFrom')!);
if (sp.get('dateTo')) where.createdAt.lte = new Date(sp.get('dateTo')!);
}
const rows = await prisma.emailLog.findMany({
where,
orderBy: { id: 'desc' },
take: 10_000,
select: {
id: true, template: true, recipient: true, subject: true, status: true,
messageId: true, locale: true, errorMessage: true, customerId: true,
adminId: true, ipAddress: true, createdAt: true, sentAt: true,
},
});
const header = 'id,template,recipient,subject,status,messageId,locale,errorMessage,customerId,adminId,ipAddress,createdAt,sentAt\n';
const body = rows.map(r => [
r.id, r.template, r.recipient, r.subject, r.status, r.messageId, r.locale,
r.errorMessage, r.customerId, r.adminId, r.ipAddress, r.createdAt?.toISOString(),
r.sentAt?.toISOString(),
].map(csvEscape).join(',')).join('\n');
const meta = await getRequestMeta();
auditLog({
adminId: session.adminId,
action: AUDIT_ACTIONS.EMAIL_EXPORT,
details: `rows:${rows.length}`,
ipAddress: meta.ip,
userAgent: meta.ua,
});
return new NextResponse(header + body, {
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="emails-${Date.now()}.csv"`,
},
});
}
  • Step 2: Commit
Terminal window
git add app/api/admin/communications/emails/export/route.ts
git commit -m "feat(api): emails CSV export"

SMS endpoint’leri yapısal olarak email’lerle birebir paralel. Aşağıdaki dosyaları sırayla oluştur, her biri için kendi commit:

  • app/api/admin/communications/sms/route.ts — Task 5.1’in kopyası, prisma.smsLog, action SMS_LIST_VIEW, select alanları SMS şeması (body, twilioSid, fromNumber)
  • app/api/admin/communications/sms/[id]/route.ts — Task 5.2 paraleli, action SMS_DETAIL_VIEW / SMS_DELETE
  • app/api/admin/communications/sms/[id]/payload/route.ts — Task 5.4 paraleli, action SMS_PAYLOAD_REVEAL, prisma.smsPayload
  • app/api/admin/communications/sms/[id]/resend/route.ts — Task 5.5 paraleli, sendSms çağırır, action SMS_RESEND
  • app/api/admin/communications/sms/export/route.ts — Task 5.6 paraleli, kolonlar SMS şeması

preview SMS için yok — body zaten plain text, doğrudan detail endpoint’inden döner.

Her dosya için ayrı commit:

  • Task 5.7: SMS list endpoint — git commit -m "feat(api): GET /admin/communications/sms liste endpoint"
  • Task 5.8: SMS detail/delete — git commit -m "feat(api): sms detail + delete endpoints"
  • Task 5.9: SMS payload reveal — git commit -m "feat(api): sms payload reveal"
  • Task 5.10: SMS resend — git commit -m "feat(api): sms resend endpoint"
  • Task 5.11: SMS export — git commit -m "feat(api): sms CSV export"

Files:

  • Create: app/api/admin/credentials/portals/route.ts

  • Step 1: Implementation

app/api/admin/credentials/portals/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getAdminSession } from '@/lib/admin-session';
import prisma from '@/lib/prisma';
import { auditLog, getRequestMeta } from '@/lib/audit-log';
import { AUDIT_ACTIONS, shouldLogListView } from '@/lib/audit-actions';
export async function GET(req: NextRequest) {
const session = await getAdminSession();
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const sp = req.nextUrl.searchParams;
const status = sp.get('status');
const type = sp.get('type');
const search = sp.get('search');
const where: any = {};
if (status) where.status = status;
if (type) where.type = type;
if (search) {
where.OR = [
{ subdomain: { contains: search, mode: 'insensitive' } },
{ customerName: { contains: search, mode: 'insensitive' } },
{ customerEmail: { contains: search, mode: 'insensitive' } },
];
}
const portals = await prisma.portalInstallation.findMany({
where,
orderBy: { provisionedAt: 'desc' },
select: {
id: true, subdomain: true, type: true, status: true,
customerName: true, customerEmail: true, portalUrl: true,
trialEndsAt: true, provisionedAt: true,
},
});
if (shouldLogListView(session.adminId, AUDIT_ACTIONS.CREDENTIAL_PORTAL_LIST_VIEW)) {
const meta = await getRequestMeta();
auditLog({
adminId: session.adminId,
action: AUDIT_ACTIONS.CREDENTIAL_PORTAL_LIST_VIEW,
ipAddress: meta.ip,
userAgent: meta.ua,
});
}
return NextResponse.json({ portals });
}
  • Step 2: Commit
Terminal window
git add app/api/admin/credentials/portals/route.ts
git commit -m "feat(api): GET /admin/credentials/portals liste"

Task 6.2: GET /admin/credentials/portals/[subdomain]

Bölüm başlığı “Task 6.2: GET /admin/credentials/portals/[subdomain]”

Files:

  • Create: app/api/admin/credentials/portals/[subdomain]/route.ts

  • Step 1: Implementation

app/api/admin/credentials/portals/[subdomain]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getAdminSession } from '@/lib/admin-session';
import prisma from '@/lib/prisma';
import { auditLog, getRequestMeta } from '@/lib/audit-log';
import { AUDIT_ACTIONS } from '@/lib/audit-actions';
interface RouteCtx { params: Promise<{ subdomain: string }>; }
export async function GET(_req: NextRequest, ctx: RouteCtx) {
const session = await getAdminSession();
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { subdomain } = await ctx.params;
const portal = await prisma.portalInstallation.findUnique({ where: { subdomain } });
if (!portal) return NextResponse.json({ error: 'Not found' }, { status: 404 });
const credential = await prisma.trialCredential.findUnique({ where: { subdomain } });
// Plaintext döndürmüyoruz — sadece metadata + masked indicator
const sanitizedCredential = credential ? {
id: credential.id,
subdomain: credential.subdomain,
portalUrl: credential.portalUrl,
adminEmail: credential.adminEmail,
hasAdminPassword: !!credential.adminPassword,
customerEmail: credential.customerEmail,
hasCustomerPassword: !!credential.customerPassword,
mailUser: credential.mailUser,
hasMailPassword: !!credential.mailPassword,
apiTokenId: credential.apiTokenId,
hasApiToken: !!credential.apiTokenPlain,
umamiWebsiteId: credential.umamiWebsiteId,
umamiShareId: credential.umamiShareId,
trialEndsAt: credential.trialEndsAt,
expired: credential.expired,
createdAt: credential.createdAt,
} : null;
const meta = await getRequestMeta();
auditLog({
adminId: session.adminId,
action: AUDIT_ACTIONS.CREDENTIAL_PORTAL_DETAIL_VIEW,
target: `portal:${subdomain}`,
ipAddress: meta.ip,
userAgent: meta.ua,
});
return NextResponse.json({ portal, credential: sanitizedCredential });
}
  • Step 2: Commit
Terminal window
git add app/api/admin/credentials/portals/[subdomain]/route.ts
git commit -m "feat(api): portal detail endpoint (sanitized credentials)"

Task 6.3: POST /admin/credentials/reveal — plaintext + audit

Bölüm başlığı “Task 6.3: POST /admin/credentials/reveal — plaintext + audit”

Files:

  • Create: app/api/admin/credentials/reveal/route.ts

  • Step 1: Implementation

app/api/admin/credentials/reveal/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getAdminSession } from '@/lib/admin-session';
import prisma from '@/lib/prisma';
import { auditLog, getRequestMeta } from '@/lib/audit-log';
import { AUDIT_ACTIONS } from '@/lib/audit-actions';
const ALLOWED_FIELDS = new Set([
'adminPassword', 'customerPassword', 'mailPassword', 'apiTokenPlain',
]);
export async function POST(req: NextRequest) {
const session = await getAdminSession();
if (!session) {
auditLog({ adminId: null, action: AUDIT_ACTIONS.CREDENTIAL_REVEAL, status: 'blocked' });
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await req.json().catch(() => ({}));
const credentialId = Number(body.credentialId);
const field = String(body.field ?? '');
const source = body.source === 'copy' ? 'copy' : 'view';
if (!Number.isFinite(credentialId)) return NextResponse.json({ error: 'Invalid credentialId' }, { status: 400 });
if (!ALLOWED_FIELDS.has(field)) return NextResponse.json({ error: 'Invalid field' }, { status: 400 });
const cred = await prisma.trialCredential.findUnique({ where: { id: credentialId } });
if (!cred) return NextResponse.json({ error: 'Not found' }, { status: 404 });
const value = (cred as Record<string, unknown>)[field] as string | null;
if (value === null) return NextResponse.json({ error: 'Field is null' }, { status: 404 });
const meta = await getRequestMeta();
auditLog({
adminId: session.adminId,
action: source === 'copy' ? AUDIT_ACTIONS.CREDENTIAL_COPY : AUDIT_ACTIONS.CREDENTIAL_REVEAL,
target: `trialCredential:${credentialId}:${field}`,
details: `subdomain:${cred.subdomain}`,
ipAddress: meta.ip,
userAgent: meta.ua,
});
return NextResponse.json({ value });
}
  • Step 2: Manuel test — anonim 401
Terminal window
curl -s -X POST http://49.12.188.137:3077/api/admin/credentials/reveal \
-H "Content-Type: application/json" \
-d '{"credentialId":1,"field":"adminPassword"}'

Beklenen: {"error":"Unauthorized"}. AdminAuditLog’da status: blocked satırı oluşmuş olmalı:

Terminal window
psql -d ecu -c "SELECT action, status FROM \"AdminAuditLog\" WHERE action = 'credential.reveal' ORDER BY id DESC LIMIT 1;"
  • Step 3: Commit
Terminal window
git add app/api/admin/credentials/reveal/route.ts
git commit -m "feat(api): credential reveal endpoint (audit + field whitelist)"

Files:

  • Create: app/api/admin/credentials/by-type/admins/route.ts

  • Create: app/api/admin/credentials/by-type/customers/route.ts

  • Create: app/api/admin/credentials/by-type/mail/route.ts

  • Create: app/api/admin/credentials/by-type/api/route.ts

  • Step 1: admins/route.ts implementation

app/api/admin/credentials/by-type/admins/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getAdminSession } from '@/lib/admin-session';
import prisma from '@/lib/prisma';
import { auditLog, getRequestMeta } from '@/lib/audit-log';
import { AUDIT_ACTIONS, shouldLogListView } from '@/lib/audit-actions';
export async function GET(req: NextRequest) {
const session = await getAdminSession();
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const sp = req.nextUrl.searchParams;
const search = sp.get('search');
const where: any = {};
if (search) {
where.OR = [
{ subdomain: { contains: search, mode: 'insensitive' } },
{ adminEmail: { contains: search, mode: 'insensitive' } },
];
}
const items = await prisma.trialCredential.findMany({
where,
orderBy: { createdAt: 'desc' },
select: {
id: true, subdomain: true, portalUrl: true,
adminEmail: true, createdAt: true,
},
});
if (shouldLogListView(session.adminId, AUDIT_ACTIONS.CREDENTIAL_TYPE_LIST_VIEW)) {
const meta = await getRequestMeta();
auditLog({
adminId: session.adminId,
action: AUDIT_ACTIONS.CREDENTIAL_TYPE_LIST_VIEW,
details: 'type:admins',
ipAddress: meta.ip,
userAgent: meta.ua,
});
}
return NextResponse.json({ items });
}
  • Step 2: customers/route.ts — yukarıdakinin kopyası, select alanı customerEmail, search OR customerEmail, details type:customers.

  • Step 3: mail/route.ts — select: id, subdomain, mailUser, createdAt, search OR mailUser, details type:mail. Where filter: mailUser != null.

  • Step 4: api/route.ts — select: id, subdomain, apiTokenId, createdAt, search OR yalnızca subdomain, details type:api. Where: apiTokenId != null.

  • Step 5: Commit

Terminal window
git add app/api/admin/credentials/by-type/
git commit -m "feat(api): credentials by-type endpoint'leri (admins/customers/mail/api)"

Bu fazda mevcut admin layout primitive’lerini (components/admin/AdminPanelContent.tsx, AdminTicketList tablosu pattern’i, AdminInvoiceList drawer pattern’i) takip et. Stil için app/admin-theme.css’teki sınıflar kullanılır.

Files:

  • Create: app/[locale]/admin/(panel)/communications/layout.tsx

  • Create: app/[locale]/admin/(panel)/communications/page.tsx

  • Create: components/admin/communications/CommunicationsTabs.tsx

  • Step 1: layout.tsx

// app/[locale]/admin/(panel)/communications/layout.tsx
import { CommunicationsTabs } from '@/components/admin/communications/CommunicationsTabs';
export default function CommunicationsLayout({ children }: { children: React.ReactNode }) {
return (
<div className="admin-page">
<header className="admin-page-header">
<h1>Communications</h1>
<CommunicationsTabs />
</header>
<div className="admin-page-body">{children}</div>
</div>
);
}
  • Step 2: page.tsx (redirect)
// app/[locale]/admin/(panel)/communications/page.tsx
import { redirect } from 'next/navigation';
export default function CommunicationsIndex({ params }: { params: Promise<{ locale: string }> }) {
return params.then(({ locale }) => redirect(`/${locale}/admin/communications/emails`));
}
  • Step 3: CommunicationsTabs.tsx
components/admin/communications/CommunicationsTabs.tsx
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
export function CommunicationsTabs() {
const pathname = usePathname();
const base = pathname.replace(/\/communications.*$/, '/communications');
return (
<nav className="admin-tabs">
<Link href={`${base}/emails`} className={pathname.includes('/emails') ? 'admin-tab active' : 'admin-tab'}>
Emails
</Link>
<Link href={`${base}/sms`} className={pathname.includes('/sms') ? 'admin-tab active' : 'admin-tab'}>
SMS
</Link>
</nav>
);
}
  • Step 4: Commit
Terminal window
git add app/[locale]/admin/\(panel\)/communications/ components/admin/communications/CommunicationsTabs.tsx
git commit -m "feat(ui): communications layout + tabs"

Files:

  • Create: components/admin/communications/EmailLogFilters.tsx

  • Step 1: Implementation

components/admin/communications/EmailLogFilters.tsx
'use client';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
import { useState } from 'react';
const TEMPLATES = [
'', 'magic-link', 'forgot-password', 'customer-welcome', 'admin-welcome',
'trial-ready', 'trial-confirmation', 'trial-rejected', 'invoice-created',
'invoice-paid', 'ticket-new', 'ticket-reply', 'security-new-login',
'sepa-order-received', 'crypto-order-received',
];
const STATUSES = ['', 'sent', 'failed', 'retrying', 'pending'];
const LOCALES = ['', 'en', 'tr', 'de', 'fr', 'es', 'it', 'nl'];
export function EmailLogFilters() {
const router = useRouter();
const path = usePathname();
const sp = useSearchParams();
const [template, setTemplate] = useState(sp.get('template') ?? '');
const [status, setStatus] = useState(sp.get('status') ?? '');
const [locale, setLocale] = useState(sp.get('locale') ?? '');
const [recipient, setRecipient] = useState(sp.get('recipient') ?? '');
const [search, setSearch] = useState(sp.get('search') ?? '');
const [dateFrom, setDateFrom] = useState(sp.get('dateFrom') ?? '');
const [dateTo, setDateTo] = useState(sp.get('dateTo') ?? '');
function apply() {
const params = new URLSearchParams();
if (template) params.set('template', template);
if (status) params.set('status', status);
if (locale) params.set('locale', locale);
if (recipient) params.set('recipient', recipient);
if (search) params.set('search', search);
if (dateFrom) params.set('dateFrom', dateFrom);
if (dateTo) params.set('dateTo', dateTo);
router.push(`${path}?${params.toString()}`);
}
function clear() {
setTemplate(''); setStatus(''); setLocale(''); setRecipient('');
setSearch(''); setDateFrom(''); setDateTo('');
router.push(path);
}
return (
<div className="admin-filter-bar">
<select value={template} onChange={e => setTemplate(e.target.value)}>
{TEMPLATES.map(t => <option key={t} value={t}>{t || 'All templates'}</option>)}
</select>
<select value={status} onChange={e => setStatus(e.target.value)}>
{STATUSES.map(s => <option key={s} value={s}>{s || 'All statuses'}</option>)}
</select>
<select value={locale} onChange={e => setLocale(e.target.value)}>
{LOCALES.map(l => <option key={l} value={l}>{l || 'All locales'}</option>)}
</select>
<input type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)} />
<input type="date" value={dateTo} onChange={e => setDateTo(e.target.value)} />
<input placeholder="Recipient" value={recipient} onChange={e => setRecipient(e.target.value)} />
<input placeholder="Body search" value={search} onChange={e => setSearch(e.target.value)} />
<button onClick={apply}>Apply</button>
<button onClick={clear}>Clear</button>
</div>
);
}
  • Step 2: Commit
Terminal window
git add components/admin/communications/EmailLogFilters.tsx
git commit -m "feat(ui): EmailLogFilters component"

Files:

  • Create: components/admin/communications/EmailLogTable.tsx

  • Create: app/[locale]/admin/(panel)/communications/emails/page.tsx

  • Step 1: EmailLogTable.tsx

components/admin/communications/EmailLogTable.tsx
'use client';
import { useState } from 'react';
import { EmailDetailDrawer } from './EmailDetailDrawer';
interface EmailRow {
id: number;
template: string;
recipient: string;
subject: string;
status: string;
locale: string | null;
messageId: string | null;
createdAt: string;
}
const STATUS_ICON: Record<string, string> = {
sent: '', failed: '', retrying: '', pending: '',
};
export function EmailLogTable({ items }: { items: EmailRow[] }) {
const [openId, setOpenId] = useState<number | null>(null);
return (
<>
<table className="admin-table">
<thead>
<tr>
<th></th>
<th>Template</th>
<th>Recipient</th>
<th>Subject</th>
<th>Status</th>
<th>When</th>
</tr>
</thead>
<tbody>
{items.map(r => (
<tr key={r.id} onClick={() => setOpenId(r.id)} className="admin-row-clickable">
<td>{STATUS_ICON[r.status] ?? '?'}</td>
<td>{r.template}</td>
<td>{r.recipient}{r.locale ? ` (${r.locale})` : ''}</td>
<td>{r.subject.slice(0, 50)}</td>
<td>{r.status}</td>
<td>{new Date(r.createdAt).toLocaleString()}</td>
</tr>
))}
{items.length === 0 && <tr><td colSpan={6}>No emails</td></tr>}
</tbody>
</table>
{openId !== null && <EmailDetailDrawer id={openId} onClose={() => setOpenId(null)} />}
</>
);
}
  • Step 2: emails/page.tsx (server component)
// app/[locale]/admin/(panel)/communications/emails/page.tsx
import { headers } from 'next/headers';
import { EmailLogFilters } from '@/components/admin/communications/EmailLogFilters';
import { EmailLogTable } from '@/components/admin/communications/EmailLogTable';
interface SearchParams {
template?: string; status?: string; locale?: string;
recipient?: string; search?: string; dateFrom?: string; dateTo?: string;
cursor?: string;
}
export default async function EmailsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const sp = await searchParams;
const params = new URLSearchParams(sp as Record<string, string>);
const h = await headers();
const cookie = h.get('cookie') ?? '';
const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? 'http://49.12.188.137:3077';
const res = await fetch(`${baseUrl}/api/admin/communications/emails?${params}`, {
headers: { cookie },
cache: 'no-store',
});
const data = await res.json();
return (
<>
<div className="admin-page-actions">
<a href={`/api/admin/communications/emails/export?${params}`} className="admin-button">↓ Export CSV</a>
</div>
<EmailLogFilters />
<p>Showing {data.items?.length ?? 0} of {data.total ?? 0}</p>
<EmailLogTable items={data.items ?? []} />
{data.nextCursor && (
<a href={`?${(() => { params.set('cursor', data.nextCursor); return params; })()}`} className="admin-button">
Load more
</a>
)}
</>
);
}
  • Step 3: Commit
Terminal window
git add components/admin/communications/EmailLogTable.tsx app/[locale]/admin/\(panel\)/communications/emails/page.tsx
git commit -m "feat(ui): emails liste sayfası + tablo"

Files:

  • Create: components/admin/communications/EmailDetailDrawer.tsx

  • Create: components/admin/communications/EmailRenderedTab.tsx

  • Create: components/admin/communications/ResendDialog.tsx

  • Step 1: EmailRenderedTab.tsx

components/admin/communications/EmailRenderedTab.tsx
'use client';
export function EmailRenderedTab({ id }: { id: number }) {
// sandbox="" — hiçbir flag yok: opaque origin, no scripts, no parent access
return (
<iframe
src={`/api/admin/communications/emails/${id}/preview`}
sandbox=""
style={{ width: '100%', minHeight: 500, border: '1px solid #ddd' }}
title={`email-${id}-rendered`}
/>
);
}
  • Step 2: ResendDialog.tsx
components/admin/communications/ResendDialog.tsx
'use client';
import { useState } from 'react';
export function ResendDialog({ id, onClose, onDone }: { id: number; onClose: () => void; onDone: () => void }) {
const [reason, setReason] = useState('');
const [busy, setBusy] = useState(false);
async function submit() {
if (reason.trim().length < 3) { alert('Sebep en az 3 karakter olmalı'); return; }
setBusy(true);
const res = await fetch(`/api/admin/communications/emails/${id}/resend`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason }),
});
setBusy(false);
const j = await res.json();
if (j.success) { alert(`Resend OK — yeni log: ${j.newEmailLogId}`); onDone(); }
else alert(`Resend failed: ${j.error ?? 'unknown'}`);
}
return (
<div className="admin-modal-backdrop" onClick={onClose}>
<div className="admin-modal" onClick={e => e.stopPropagation()}>
<h3>↻ Resend email #{id}</h3>
<label>Sebep (zorunlu, min 3 karakter)</label>
<textarea value={reason} onChange={e => setReason(e.target.value)} rows={3} style={{ width: '100%' }} />
<div className="admin-modal-actions">
<button onClick={onClose} disabled={busy}>İptal</button>
<button onClick={submit} disabled={busy || reason.trim().length < 3}>
{busy ? 'Sending…' : '↻ Yeniden gönder'}
</button>
</div>
</div>
</div>
);
}
  • Step 3: EmailDetailDrawer.tsx
components/admin/communications/EmailDetailDrawer.tsx
'use client';
import { useEffect, useState } from 'react';
import { EmailRenderedTab } from './EmailRenderedTab';
import { ResendDialog } from './ResendDialog';
type Tab = 'rendered' | 'text' | 'headers' | 'payload';
interface LogDetail {
id: number; template: string; recipient: string; subject: string;
text: string; headers: Record<string, string>; status: string;
messageId: string | null; createdAt: string; sentAt: string | null;
locale: string | null; customerId: number | null;
hasPayload: boolean;
}
export function EmailDetailDrawer({ id, onClose }: { id: number; onClose: () => void }) {
const [data, setData] = useState<LogDetail | null>(null);
const [tab, setTab] = useState<Tab>('rendered');
const [payload, setPayload] = useState<unknown | null>(null);
const [resendOpen, setResendOpen] = useState(false);
useEffect(() => {
fetch(`/api/admin/communications/emails/${id}`).then(r => r.json()).then(j => setData(j.log));
}, [id]);
async function reveal(source: 'view' | 'copy') {
const res = await fetch(`/api/admin/communications/emails/${id}/payload`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source }),
});
const j = await res.json();
if (j.payload) {
setPayload(j.payload);
if (source === 'copy') navigator.clipboard.writeText(JSON.stringify(j.payload, null, 2));
setTimeout(() => setPayload(null), 30_000);
}
}
async function del() {
if (!confirm(`Email #${id} silinecek. Emin misin?`)) return;
await fetch(`/api/admin/communications/emails/${id}`, { method: 'DELETE' });
onClose();
}
if (!data) return <div className="admin-drawer">Loading…</div>;
return (
<>
<div className="admin-drawer-backdrop" onClick={onClose} />
<aside className="admin-drawer">
<header>
<button onClick={onClose}></button>
<h2>Email #{data.id}</h2>
<span className={`badge badge-${data.status}`}>{data.status}</span>
</header>
<dl>
<dt>Template</dt><dd>{data.template}</dd>
<dt>To</dt><dd>{data.recipient}</dd>
<dt>Subject</dt><dd>{data.subject}</dd>
<dt>Locale</dt><dd>{data.locale ?? ''}</dd>
<dt>Sent</dt><dd>{data.sentAt ?? ''}</dd>
<dt>Message-ID</dt><dd>{data.messageId ?? ''}</dd>
</dl>
<nav className="admin-tabs">
{(['rendered', 'text', 'headers', 'payload'] as Tab[]).map(t => (
<button key={t} className={tab === t ? 'admin-tab active' : 'admin-tab'} onClick={() => setTab(t)}>
{t}
</button>
))}
</nav>
<section className="admin-drawer-body">
{tab === 'rendered' && <EmailRenderedTab id={data.id} />}
{tab === 'text' && <pre>{data.text}</pre>}
{tab === 'headers' && <pre>{JSON.stringify(data.headers, null, 2)}</pre>}
{tab === 'payload' && (
<div>
{!data.hasPayload && <p>No payload stored.</p>}
{data.hasPayload && payload === null && (
<button onClick={() => reveal('view')}>🔓 Reveal encrypted payload</button>
)}
{payload !== null && (
<>
<pre>{JSON.stringify(payload, null, 2)}</pre>
<button onClick={() => reveal('copy')}>📋 Copy</button>
<p>⚠ auto-mask in 30s</p>
</>
)}
</div>
)}
</section>
<footer>
<button onClick={() => setResendOpen(true)}>↻ Resend</button>
<button onClick={del}>🗑 Delete</button>
</footer>
</aside>
{resendOpen && <ResendDialog id={data.id} onClose={() => setResendOpen(false)} onDone={() => { setResendOpen(false); onClose(); }} />}
</>
);
}
  • Step 4: Manuel UI test

npm run dev çalıştır, admin login, /admin/communications/emails aç. Bir satıra tıkla — drawer açılmalı, 4 tab çalışmalı, Rendered tab iframe içinde HTML görünmeli, Reveal Payload butonu plaintext göstermeli. <script>alert(1)</script> içeren bir log oluşturup XSS çalışmadığını manuel doğrula.

  • Step 5: Commit
Terminal window
git add components/admin/communications/EmailDetailDrawer.tsx components/admin/communications/EmailRenderedTab.tsx components/admin/communications/ResendDialog.tsx
git commit -m "feat(ui): email detail drawer + sandboxed iframe + reveal + resend"

Files:

  • Create: components/admin/communications/SmsLogTable.tsx
  • Create: components/admin/communications/SmsDetailDrawer.tsx
  • Create: app/[locale]/admin/(panel)/communications/sms/page.tsx

SMS UI yapısı email’lerle aynı, basitleştirmeler:

  • Filtre yok (sadece template, status, recipient, date)

  • Drawer’da 3 tab: Body / Twilio Response / Payload

  • Resend var, preview yok (body zaten plain text)

  • Step 1: SmsLogTable.tsxEmailLogTable paterni, kolonlar: status, template, recipient, body preview (60 char), Twilio SID, time. SmsDetailDrawer import et.

  • Step 2: SmsDetailDrawer.tsxEmailDetailDrawer paterni, tab’lar: body | twilio | payload. Body sandboxed iframe yerine <pre> (zaten text). Twilio tab’ı data.twilioSid ve raw response gösterir.

  • Step 3: sms/page.tsx — emails/page.tsx paraleli, /api/admin/communications/sms çağırır.

  • Step 4: sms/[id]/page.tsx — emails/[id]/page.tsx paraleli, deep-link standalone view.

  • Step 5: Commit

Terminal window
git add components/admin/communications/Sms* app/[locale]/admin/\(panel\)/communications/sms/
git commit -m "feat(ui): SMS log liste + detail drawer"

Files:

  • Create: app/[locale]/admin/(panel)/communications/emails/[id]/page.tsx

  • Step 1: Implementation

// app/[locale]/admin/(panel)/communications/emails/[id]/page.tsx
import { headers } from 'next/headers';
import { EmailDetailDrawer } from '@/components/admin/communications/EmailDetailDrawer';
import { redirect } from 'next/navigation';
export default async function EmailDetailPage({
params,
}: { params: Promise<{ id: string; locale: string }> }) {
const { id, locale } = await params;
const numId = parseInt(id, 10);
if (!Number.isFinite(numId)) redirect(`/${locale}/admin/communications/emails`);
return <EmailDetailDrawer id={numId} onClose={() => {}} />;
}

Drawer client component, modal-style. Standalone page için onClose location.back() yapan bir wrapper component eklemek opsiyonel.

  • Step 2: Commit
Terminal window
git add app/[locale]/admin/\(panel\)/communications/emails/[id]/
git commit -m "feat(ui): standalone email detail deep-link sayfası"

Files:

  • Create: app/[locale]/admin/(panel)/credentials/layout.tsx

  • Create: app/[locale]/admin/(panel)/credentials/page.tsx

  • Create: components/admin/credentials/CredentialsTabs.tsx

  • Step 1: layout.tsx

// app/[locale]/admin/(panel)/credentials/layout.tsx
import { CredentialsTabs } from '@/components/admin/credentials/CredentialsTabs';
export default function CredentialsLayout({ children }: { children: React.ReactNode }) {
return (
<div className="admin-page">
<header className="admin-page-header">
<h1>Credentials</h1>
<CredentialsTabs />
</header>
<div className="admin-page-body">{children}</div>
</div>
);
}
  • Step 2: page.tsx — redirect
// app/[locale]/admin/(panel)/credentials/page.tsx
import { redirect } from 'next/navigation';
export default async function Index({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
redirect(`/${locale}/admin/credentials/by-portal`);
}
  • Step 3: CredentialsTabs.tsx
components/admin/credentials/CredentialsTabs.tsx
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
const TYPE_TABS = ['admins', 'customers', 'mail', 'api'];
export function CredentialsTabs() {
const pathname = usePathname();
const base = pathname.replace(/\/credentials.*$/, '/credentials');
const inByType = pathname.includes('/by-type');
return (
<nav className="admin-tabs">
<Link href={`${base}/by-portal`} className={pathname.includes('/by-portal') ? 'admin-tab active' : 'admin-tab'}>
By Portal
</Link>
<Link href={`${base}/by-type/admins`} className={inByType ? 'admin-tab active' : 'admin-tab'}>
By Type
</Link>
{inByType && (
<span className="admin-subtabs">
{TYPE_TABS.map(t => (
<Link key={t} href={`${base}/by-type/${t}`}
className={pathname.endsWith(`/${t}`) ? 'admin-subtab active' : 'admin-subtab'}>
{t}
</Link>
))}
</span>
)}
</nav>
);
}
  • Step 4: Commit
Terminal window
git add app/[locale]/admin/\(panel\)/credentials/layout.tsx app/[locale]/admin/\(panel\)/credentials/page.tsx components/admin/credentials/CredentialsTabs.tsx
git commit -m "feat(ui): credentials layout + tabs (by-portal/by-type)"

Files:

  • Create: components/admin/credentials/PortalListTable.tsx

  • Create: app/[locale]/admin/(panel)/credentials/by-portal/page.tsx

  • Step 1: PortalListTable.tsx

components/admin/credentials/PortalListTable.tsx
'use client';
import Link from 'next/link';
interface Portal {
id: number; subdomain: string; type: string; status: string;
customerName: string; customerEmail: string;
trialEndsAt: string | null; provisionedAt: string;
}
export function PortalListTable({ portals, locale }: { portals: Portal[]; locale: string }) {
return (
<table className="admin-table">
<thead>
<tr>
<th>Subdomain</th><th>Customer</th><th>Type</th>
<th>Status</th><th>Trial expires</th><th>Provisioned</th>
</tr>
</thead>
<tbody>
{portals.map(p => (
<tr key={p.id}>
<td><Link href={`/${locale}/admin/credentials/by-portal/${p.subdomain}`}>{p.subdomain}</Link></td>
<td>{p.customerName}<br /><small>{p.customerEmail}</small></td>
<td><span className={`badge badge-${p.type}`}>{p.type}</span></td>
<td><span className={`badge badge-${p.status}`}>{p.status}</span></td>
<td>{p.trialEndsAt ? new Date(p.trialEndsAt).toLocaleDateString() : ''}</td>
<td>{new Date(p.provisionedAt).toLocaleDateString()}</td>
</tr>
))}
{portals.length === 0 && <tr><td colSpan={6}>No portals</td></tr>}
</tbody>
</table>
);
}
  • Step 2: by-portal/page.tsx
// app/[locale]/admin/(panel)/credentials/by-portal/page.tsx
import { headers } from 'next/headers';
import { PortalListTable } from '@/components/admin/credentials/PortalListTable';
export default async function ByPortalPage({
params, searchParams,
}: { params: Promise<{ locale: string }>; searchParams: Promise<{ search?: string; status?: string; type?: string }> }) {
const { locale } = await params;
const sp = await searchParams;
const qs = new URLSearchParams(sp as Record<string, string>).toString();
const h = await headers();
const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? 'http://49.12.188.137:3077';
const res = await fetch(`${baseUrl}/api/admin/credentials/portals?${qs}`, {
headers: { cookie: h.get('cookie') ?? '' },
cache: 'no-store',
});
const data = await res.json();
return <PortalListTable portals={data.portals ?? []} locale={locale} />;
}
  • Step 3: Commit
Terminal window
git add components/admin/credentials/PortalListTable.tsx app/[locale]/admin/\(panel\)/credentials/by-portal/page.tsx
git commit -m "feat(ui): credentials by-portal liste sayfası"

Task 8.3: Portal detail page + credential blocks + reveal

Bölüm başlığı “Task 8.3: Portal detail page + credential blocks + reveal”

Files:

  • Create: components/admin/credentials/RevealField.tsx

  • Create: components/admin/credentials/CredentialBlock.tsx

  • Create: components/admin/credentials/PortalDetailHeader.tsx

  • Create: app/[locale]/admin/(panel)/credentials/by-portal/[subdomain]/page.tsx

  • Step 1: RevealField.tsx

components/admin/credentials/RevealField.tsx
'use client';
import { useState, useEffect } from 'react';
interface Props {
credentialId: number;
field: 'adminPassword' | 'customerPassword' | 'mailPassword' | 'apiTokenPlain';
hasValue: boolean;
}
const REVEAL_TIMEOUT_MS = 30_000;
export function RevealField({ credentialId, field, hasValue }: Props) {
const [value, setValue] = useState<string | null>(null);
const [secondsLeft, setSecondsLeft] = useState(0);
useEffect(() => {
if (value === null) return;
setSecondsLeft(REVEAL_TIMEOUT_MS / 1000);
const tick = setInterval(() => setSecondsLeft(s => Math.max(0, s - 1)), 1000);
const mask = setTimeout(() => setValue(null), REVEAL_TIMEOUT_MS);
return () => { clearInterval(tick); clearTimeout(mask); };
}, [value]);
async function call(source: 'view' | 'copy'): Promise<string | null> {
const res = await fetch('/api/admin/credentials/reveal', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credentialId, field, source }),
});
if (!res.ok) { alert('Reveal failed'); return null; }
const j = await res.json();
return j.value as string;
}
async function reveal() {
const v = await call('view');
if (v) setValue(v);
}
async function copy() {
const v = await call('copy');
if (v) {
await navigator.clipboard.writeText(v);
alert('Kopyalandı');
}
}
if (!hasValue) return <span className="muted">— null —</span>;
return (
<span className="reveal-field">
<code>{value ?? '••••••••••••'}</code>
{value === null && <button onClick={reveal} className="reveal-btn">🔓 Reveal</button>}
{value !== null && <span className="reveal-countdown">auto-mask in {secondsLeft}s</span>}
<button onClick={copy} className="copy-btn">📋 Copy</button>
</span>
);
}
  • Step 2: CredentialBlock.tsx
components/admin/credentials/CredentialBlock.tsx
import { RevealField } from './RevealField';
interface Field {
label: string;
value: string | null;
reveal?: { credentialId: number; field: 'adminPassword' | 'customerPassword' | 'mailPassword' | 'apiTokenPlain'; hasValue: boolean };
link?: string;
}
export function CredentialBlock({ icon, title, fields }: { icon: string; title: string; fields: Field[] }) {
return (
<section className="credential-block">
<h3>{icon} {title}</h3>
<dl>
{fields.map(f => (
<div key={f.label} className="credential-row">
<dt>{f.label}</dt>
<dd>
{f.reveal ? (
<RevealField {...f.reveal} />
) : f.link ? (
<a href={f.link} target="_blank" rel="noreferrer">{f.value}</a>
) : (
<code>{f.value ?? ''}</code>
)}
</dd>
</div>
))}
</dl>
</section>
);
}
  • Step 3: PortalDetailHeader.tsx
components/admin/credentials/PortalDetailHeader.tsx
interface Portal {
subdomain: string; customerName: string; customerEmail: string;
type: string; status: string; trialEndsAt: string | null;
provisionedAt: string; portalUrl: string;
}
export function PortalDetailHeader({ portal }: { portal: Portal }) {
const trialDaysLeft = portal.trialEndsAt
? Math.ceil((new Date(portal.trialEndsAt).getTime() - Date.now()) / 86_400_000)
: null;
return (
<header className="portal-detail-header">
<h2>{portal.subdomain}</h2>
<span className={`badge badge-${portal.type}`}>{portal.type}</span>
<span className={`badge badge-${portal.status}`}>{portal.status}</span>
<dl>
<dt>Customer</dt><dd>{portal.customerName} ({portal.customerEmail})</dd>
<dt>Provisioned</dt><dd>{new Date(portal.provisionedAt).toLocaleString()}</dd>
{trialDaysLeft !== null && (
<><dt>Trial ends</dt><dd>{new Date(portal.trialEndsAt!).toLocaleDateString()} (in {trialDaysLeft} days)</dd></>
)}
</dl>
<a href={portal.portalUrl} target="_blank" rel="noreferrer">Open Portal ↗</a>
</header>
);
}
  • Step 4: by-portal/[subdomain]/page.tsx
// app/[locale]/admin/(panel)/credentials/by-portal/[subdomain]/page.tsx
import { headers } from 'next/headers';
import { notFound } from 'next/navigation';
import { PortalDetailHeader } from '@/components/admin/credentials/PortalDetailHeader';
import { CredentialBlock } from '@/components/admin/credentials/CredentialBlock';
export default async function PortalDetailPage({ params }: { params: Promise<{ subdomain: string }> }) {
const { subdomain } = await params;
const h = await headers();
const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? 'http://49.12.188.137:3077';
const res = await fetch(`${baseUrl}/api/admin/credentials/portals/${subdomain}`, {
headers: { cookie: h.get('cookie') ?? '' },
cache: 'no-store',
});
if (!res.ok) notFound();
const { portal, credential } = await res.json();
if (!portal) notFound();
const cid = credential?.id ?? 0;
return (
<article>
<PortalDetailHeader portal={portal} />
<CredentialBlock icon="🔐" title="Admin Panel" fields={[
{ label: 'URL', value: `${portal.portalUrl}/admin`, link: `${portal.portalUrl}/admin` },
{ label: 'Email', value: credential?.adminEmail ?? null },
{ label: 'Password', value: null, reveal: { credentialId: cid, field: 'adminPassword', hasValue: !!credential?.hasAdminPassword } },
]} />
<CredentialBlock icon="👤" title="Customer Portal" fields={[
{ label: 'URL', value: portal.portalUrl, link: portal.portalUrl },
{ label: 'Email', value: credential?.customerEmail ?? null },
{ label: 'Password', value: null, reveal: { credentialId: cid, field: 'customerPassword', hasValue: !!credential?.hasCustomerPassword } },
]} />
<CredentialBlock icon="📧" title="Mail (SMTP/IMAP)" fields={[
{ label: 'User', value: credential?.mailUser ?? null },
{ label: 'Password', value: null, reveal: { credentialId: cid, field: 'mailPassword', hasValue: !!credential?.hasMailPassword } },
]} />
<CredentialBlock icon="🔑" title="API Token" fields={[
{ label: 'Token ID', value: credential?.apiTokenId ? String(credential.apiTokenId) : null },
{ label: 'Plain', value: null, reveal: { credentialId: cid, field: 'apiTokenPlain', hasValue: !!credential?.hasApiToken } },
]} />
<p className="muted">⚠ Tüm reveal/copy aksiyonları audit log'a kaydedilir.</p>
</article>
);
}
  • Step 5: Manuel UI test

/admin/credentials/by-portal/<existing_subdomain> aç. 4 credential bloğu görünmeli, Reveal tıkla → plaintext görünmeli, 30 sn’de mask. Audit log:

Terminal window
psql -d ecu -c "SELECT action, target, details FROM \"AdminAuditLog\" WHERE action LIKE 'credential.%' ORDER BY id DESC LIMIT 5;"
  • Step 6: Commit
Terminal window
git add components/admin/credentials/ app/[locale]/admin/\(panel\)/credentials/by-portal/[subdomain]/
git commit -m "feat(ui): portal detail + credential blocks + reveal/copy"

Files:

  • Create: components/admin/credentials/TypeListTable.tsx

  • Create: app/[locale]/admin/(panel)/credentials/by-type/admins/page.tsx

  • Create: app/[locale]/admin/(panel)/credentials/by-type/customers/page.tsx

  • Create: app/[locale]/admin/(panel)/credentials/by-type/mail/page.tsx

  • Create: app/[locale]/admin/(panel)/credentials/by-type/api/page.tsx

  • Step 1: TypeListTable.tsx

components/admin/credentials/TypeListTable.tsx
'use client';
import Link from 'next/link';
import { RevealField } from './RevealField';
type FieldKey = 'adminPassword' | 'customerPassword' | 'mailPassword' | 'apiTokenPlain';
interface Item {
id: number;
subdomain: string;
identifier: string | null; // adminEmail / customerEmail / mailUser / "Token #N"
createdAt: string;
}
export function TypeListTable({ items, fieldKey, identifierLabel, locale }: {
items: Item[];
fieldKey: FieldKey;
identifierLabel: string;
locale: string;
}) {
return (
<table className="admin-table">
<thead>
<tr>
<th>Portal</th>
<th>{identifierLabel}</th>
<th>Secret</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{items.map(i => (
<tr key={i.id}>
<td><Link href={`/${locale}/admin/credentials/by-portal/${i.subdomain}`}>{i.subdomain}</Link></td>
<td>{i.identifier ?? ''}</td>
<td><RevealField credentialId={i.id} field={fieldKey} hasValue={true} /></td>
<td>{new Date(i.createdAt).toLocaleDateString()}</td>
</tr>
))}
{items.length === 0 && <tr><td colSpan={4}>No items</td></tr>}
</tbody>
</table>
);
}
  • Step 2: by-type/admins/page.tsx
// app/[locale]/admin/(panel)/credentials/by-type/admins/page.tsx
import { headers } from 'next/headers';
import { TypeListTable } from '@/components/admin/credentials/TypeListTable';
export default async function AdminsPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
const h = await headers();
const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? 'http://49.12.188.137:3077';
const res = await fetch(`${baseUrl}/api/admin/credentials/by-type/admins`, {
headers: { cookie: h.get('cookie') ?? '' },
cache: 'no-store',
});
const data = await res.json();
const items = (data.items ?? []).map((r: any) => ({
id: r.id, subdomain: r.subdomain, identifier: r.adminEmail, createdAt: r.createdAt,
}));
return <TypeListTable items={items} fieldKey="adminPassword" identifierLabel="Email" locale={locale} />;
}
  • Step 3: customers/page.tsx — yukarıdakinin kopyası, endpoint by-type/customers, identifier: r.customerEmail, fieldKey="customerPassword".

  • Step 4: mail/page.tsx — endpoint by-type/mail, identifier: r.mailUser, fieldKey="mailPassword", identifierLabel="SMTP User".

  • Step 5: api/page.tsx — endpoint by-type/api, identifier: r.apiTokenId ? Token #${r.apiTokenId} : null, fieldKey="apiTokenPlain", identifierLabel="Token ID".

  • Step 6: Commit

Terminal window
git add components/admin/credentials/TypeListTable.tsx app/[locale]/admin/\(panel\)/credentials/by-type/
git commit -m "feat(ui): credentials by-type sayfaları (admins/customers/mail/api)"

Files:

  • Create: app/api/cron/log-retention/route.ts

  • Step 1: Implementation

app/api/cron/log-retention/route.ts
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
export async function POST(req: NextRequest) {
const auth = req.headers.get('authorization');
const expected = `Bearer ${process.env.CRON_SECRET}`;
if (!process.env.CRON_SECRET || auth !== expected) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const days = parseInt(process.env.LOG_RETENTION_DAYS ?? '90', 10);
const cutoff = new Date(Date.now() - days * 86_400_000);
const emails = await prisma.emailLog.deleteMany({ where: { createdAt: { lt: cutoff } } });
const sms = await prisma.smsLog.deleteMany({ where: { createdAt: { lt: cutoff } } });
console.log(`[retention] purged emails=${emails.count} sms=${sms.count} cutoff=${cutoff.toISOString()}`);
return NextResponse.json({ purgedEmails: emails.count, purgedSms: sms.count, cutoff });
}
export async function GET(req: NextRequest) {
return POST(req);
}
  • Step 2: Manuel test
Terminal window
# locally test against dev DB — geçici eski kayıt oluştur
psql -d ecu -c "INSERT INTO \"EmailLog\" (template,recipient,subject,html,text,headers,status,\"createdAt\") VALUES ('smoke','x@y','s','<p>x</p>','x','{}','sent', NOW() - INTERVAL '100 days') RETURNING id;"
# cron çağır
curl -s -X POST http://49.12.188.137:3077/api/cron/log-retention \
-H "Authorization: Bearer $CRON_SECRET" | jq
psql -d ecu -c "SELECT count(*) FROM \"EmailLog\" WHERE \"createdAt\" < NOW() - INTERVAL '90 days';"

Beklenen: purgedEmails: 1, count = 0.

  • Step 3: Crontab veya systemd timer’a ekle

/etc/cron.d/ecu-log-retention veya manuel cron entry:

0 4 * * * curl -s -X POST -H "Authorization: Bearer $CRON_SECRET" http://localhost:3077/api/cron/log-retention >> /var/log/ecu-retention.log 2>&1

Bu adım deploy zamanı yapılır, plan’da sadece referans.

  • Step 4: Commit
Terminal window
git add app/api/cron/log-retention/route.ts
git commit -m "feat(cron): log retention 90 gün cleanup endpoint"

Files:

  • Modify: components/admin/AdminSidebar.tsx

  • Step 1: Mevcut nav item listesini bul ve iki yeni grup ekle

AdminSidebar.tsx içinde Sessions veya Settings nav item’ından önce:

{/* Communications group */}
<NavGroup label="Communications" icon="📨">
<NavItem href={`/${locale}/admin/communications/emails`} label="Emails" />
<NavItem href={`/${locale}/admin/communications/sms`} label="SMS" />
</NavGroup>
{/* Credentials group */}
<NavGroup label="Credentials" icon="🔐">
<NavItem href={`/${locale}/admin/credentials/by-portal`} label="By Portal" />
<NavItem href={`/${locale}/admin/credentials/by-type/admins`} label="By Type" />
</NavGroup>

Mevcut AdminSidebar API’si farklıysa (NavGroup yoksa), Sessions item’ının taklit ettiği aynı pattern’i kullan — engineer dosyayı okuyup uyarlasın.

  • Step 2: Manuel test

npm run dev → admin login → sidebar’da yeni iki grup görünmeli ve nav linkler çalışmalı.

  • Step 3: Commit
Terminal window
git add components/admin/AdminSidebar.tsx
git commit -m "feat(ui): sidebar Communications + Credentials nav grupları"

Files:

  • Modify: mevcut app/[locale]/admin/(panel)/security/page.tsx veya audit log filter component’i

  • Step 1: Action filter listesine yeni kategoriler ekle

Mevcut audit log UI’da action dropdown veya chip list’i bul. Şu prefix gruplarını ekle:

  • Communications (action LIKE 'communications.%')
  • Credentials (action LIKE 'credential.%')

Eğer mevcut UI yapısı farklıysa, engineer’a kalmış — bu task düşük öncelikli.

  • Step 2: Commit
Terminal window
git add app/[locale]/admin/\(panel\)/security/
git commit -m "feat(ui): audit log filter'a Communications/Credentials kategorileri"

Bu faz opsiyonel ve tamamen geriye dönük uyumlu. Ana feature çalışıyor; bu sadece log’larda customerId/adminId eksikliğini tamamlıyor. Tek seferde değil, küçük PR’lar halinde yapılabilir.

Task 11.1: Mail call site enrichment — admin auth grubu

Bölüm başlığı “Task 11.1: Mail call site enrichment — admin auth grubu”

Files:

  • Modify:

    • app/api/admin/auth/magic-link/send/route.ts
    • app/api/admin/auth/forgot-password/send/route.ts
    • app/api/admin/auth/login/route.ts
  • Step 1: Her dosyada sendMail çağrısına context ekle

Örnek pattern:

// Önce
const { ip, ua } = await getRequestMeta();
// sendMail çağrısı
await sendMail({
to: admin.email,
template: 'magic-link',
data: { name: admin.name, magicLink, ipAddress: ip, locale },
context: { adminId: admin.id, ipAddress: ip },
});

adminId, customerId ve ipAddress alanlarını duruma göre doldur. Anonim/cron çağrılarında null bırak.

  • Step 2: Commit
Terminal window
git add app/api/admin/auth/
git commit -m "chore(mail): admin auth call site'larında EmailLog context enrichment"

Spec’teki call site listesine göre grup grup tekrarla. Her grup ayrı commit:

  • 11.2 Admin yönetim grubu: app/api/admin/{users,trial,orders}/...git commit -m "chore(mail): admin yönetim call site enrichment"
  • 11.3 Payment grubu: app/api/payment/{confirm-sepa,confirm-crypto,webhook}/...git commit -m "chore(mail): payment call site enrichment"
  • 11.4 Customer auth/2fa grubu: app/api/customer/{auth,2fa,account}/...git commit -m "chore(mail): customer call site enrichment"
  • 11.5 Cron + contact: app/api/cron/trial-expiry/..., app/api/contact/...git commit -m "chore(mail): cron + contact call site enrichment"
  • 11.6 Server actions: app/lib/customer-actions.ts, app/lib/admin-customer-actions.tsgit commit -m "chore(mail): server action call site enrichment"
  • 11.7 Ticket notifications: lib/ticket-notifications.tsgit commit -m "chore(mail): ticket notifications call site enrichment"

Files:

  • Modify:

    • app/api/admin/auth/sms-otp/{send,verify}/route.ts
    • app/api/customer/auth/sms-otp/{send,verify}/route.ts
    • app/api/contact/route.ts
  • Step 1: Her dosyada sendSms çağrısına context ekle

  • Step 2: Commit

Terminal window
git add app/api/admin/auth/sms-otp/ app/api/customer/auth/sms-otp/ app/api/contact/
git commit -m "chore(sms): SMS call site enrichment"

Implementation tamamlandıktan sonra şu kontrolleri yap:

  • npm test tüm unit testler geçiyor
  • npx tsc --noEmit type hata yok
  • npm run build başarılı (Next.js production build)
  • Manuel: /admin/communications/emails listesinde mail görünüyor (yeni mail tetikle: /admin/auth/magic-link/send)
  • Manuel: bir mail satırına tıkla → drawer açılıyor → 4 tab çalışıyor → Rendered tab iframe içinde HTML görünüyor → script içeren mail XSS atmıyor
  • Manuel: Reveal Payload → plaintext görünüyor → 30 sn sonra mask
  • Manuel: Resend → reason girdir → yeni log oluşuyor, audit chain OLD → NEW
  • Manuel: Delete → satır silinir, payload cascade ile gider
  • Manuel: CSV export indirilir, içerik doğru
  • Manuel: SMS tarafı paralel davranış
  • Manuel: /admin/credentials/by-portal portal listeleniyor
  • Manuel: portal detay → 4 credential bloğu → reveal mask + plaintext + 30 sn timer
  • Manuel: by-type/admins listesinde reveal çalışıyor
  • Manuel: anonim curl reveal endpoint’i 401, audit’e status: blocked
  • Manuel: cron POST → 90 gün öncesi siliniyor
  • Audit log’da communications.* ve credential.* aksiyonları görünüyor; list_view günde 1 kez kayıtlı

  • Mevcut admin layout primitive’leri: bu plan admin-page, admin-table, admin-tabs, admin-drawer, admin-modal-backdrop, admin-modal, badge-* CSS sınıflarını varsayar. Mevcut sınıflar app/admin-theme.css içinde tanımlı; eksik olanları engineer eklemeli.
  • getAdminSession: bu plan bu helper’ın varlığını varsayar. Kod tabanında app/api/admin/users/route.ts veya benzeri rotalarda gerçek pattern bulunup adapt edilmeli (iron-session kullanılıyor).
  • @/generated/prisma import path: mevcut codebase’de Prisma client alias @/generated/prisma veya @prisma/client olarak kullanılıyor olabilir; lib/prisma.ts’deki re-export’u takip et.
  • Test izolasyonu: node:test paralel çalıştığında DB’ye yazıyorsa unique recipient ile filter ettim, ama daha güvenli için her test kendi beforeEach ile cleanup yapabilir. Şu an minimal yeterli.
  • DRY hatırlatma: SMS endpoint/UI’larında mail pattern birebir tekrar — engineer copy-paste yerine kısa helper extraction (örn. buildLogListWhere) yapabilir, ama mecburi değil.