Communications & Credentials Audit Implementation Plan
DerinFor 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
File Structure
Bölüm başlığı “File Structure”Yeni dosyalar
Bölüm başlığı “Yeni dosyalar”Core lib:
lib/crypto/payload-cipher.ts— AES-256-GCM encrypt/decrypt (ayrı key)lib/crypto/payload-cipher.test.ts— round-trip + tamper testlib/audit-actions.ts— yeni action constants + dedup helperlib/audit-actions.test.ts— dedup logic test
Prisma:
prisma/migrations/<timestamp>_add_communications_logs/migration.sqlprisma/schema.prisma— UPDATE (4 yeni model)
API endpoints — communications:
app/api/admin/communications/emails/route.tsapp/api/admin/communications/emails/[id]/route.tsapp/api/admin/communications/emails/[id]/preview/route.tsapp/api/admin/communications/emails/[id]/payload/route.tsapp/api/admin/communications/emails/[id]/resend/route.tsapp/api/admin/communications/emails/export/route.tsapp/api/admin/communications/sms/route.tsapp/api/admin/communications/sms/[id]/route.tsapp/api/admin/communications/sms/[id]/payload/route.tsapp/api/admin/communications/sms/[id]/resend/route.tsapp/api/admin/communications/sms/export/route.ts
API endpoints — credentials:
app/api/admin/credentials/portals/route.tsapp/api/admin/credentials/portals/[subdomain]/route.tsapp/api/admin/credentials/by-type/admins/route.tsapp/api/admin/credentials/by-type/customers/route.tsapp/api/admin/credentials/by-type/mail/route.tsapp/api/admin/credentials/by-type/api/route.tsapp/api/admin/credentials/reveal/route.ts
Cron:
app/api/cron/log-retention/route.ts
Admin UI — communications:
app/[locale]/admin/(panel)/communications/layout.tsxapp/[locale]/admin/(panel)/communications/page.tsxapp/[locale]/admin/(panel)/communications/emails/page.tsxapp/[locale]/admin/(panel)/communications/emails/[id]/page.tsxapp/[locale]/admin/(panel)/communications/sms/page.tsxapp/[locale]/admin/(panel)/communications/sms/[id]/page.tsxcomponents/admin/communications/EmailLogTable.tsxcomponents/admin/communications/EmailLogFilters.tsxcomponents/admin/communications/EmailDetailDrawer.tsxcomponents/admin/communications/EmailRenderedTab.tsxcomponents/admin/communications/ResendDialog.tsxcomponents/admin/communications/SmsLogTable.tsxcomponents/admin/communications/SmsDetailDrawer.tsxcomponents/admin/communications/CommunicationsTabs.tsx
Admin UI — credentials:
app/[locale]/admin/(panel)/credentials/layout.tsxapp/[locale]/admin/(panel)/credentials/page.tsxapp/[locale]/admin/(panel)/credentials/by-portal/page.tsxapp/[locale]/admin/(panel)/credentials/by-portal/[subdomain]/page.tsxapp/[locale]/admin/(panel)/credentials/by-type/admins/page.tsxapp/[locale]/admin/(panel)/credentials/by-type/customers/page.tsxapp/[locale]/admin/(panel)/credentials/by-type/mail/page.tsxapp/[locale]/admin/(panel)/credentials/by-type/api/page.tsxcomponents/admin/credentials/CredentialsTabs.tsxcomponents/admin/credentials/PortalListTable.tsxcomponents/admin/credentials/PortalDetailHeader.tsxcomponents/admin/credentials/CredentialBlock.tsxcomponents/admin/credentials/RevealField.tsxcomponents/admin/credentials/TypeListTable.tsx
Değiştirilecek dosyalar
Bölüm başlığı “Değiştirilecek dosyalar”prisma/schema.prisma— 4 yeni modellib/mail/service.ts— log yazımı + context parametresilib/mail/queue.ts—QueuedEmail.emailLogIdfield, success/fail updatelib/sms/service.ts— log yazımı + context parametresipackage.json—testscripts.env.example—MAIL_PAYLOAD_KEY,LOG_RETENTION_DAYScomponents/admin/AdminSidebar.tsx— yeni nav item’lar (Communications, Credentials)
Phase 0: Test Runner & Crypto Foundation
Bölüm başlığı “Phase 0: Test Runner & Crypto Foundation”Task 0.1: Test runner script ekle
Bölüm başlığı “Task 0.1: Test runner script ekle”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:
npm testBeklenen: # pass 1 çıktısı, exit code 0.
- Step 3: Smoke dosyasını sil
rm lib/_smoke.test.ts- Step 4: Commit
git add package.jsongit commit -m "chore(test): node:test runner script ekle"Task 0.2: payload-cipher util — failing test
Bölüm başlığı “Task 0.2: payload-cipher util — failing test”Files:
-
Create:
lib/crypto/payload-cipher.test.ts -
Step 1: Test dosyasını yaz
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
npm test -- "lib/crypto/payload-cipher.test.ts"Beklenen: Cannot find module './payload-cipher.ts'
Task 0.3: payload-cipher implementation
Bölüm başlığı “Task 0.3: payload-cipher implementation”Files:
-
Create:
lib/crypto/payload-cipher.ts -
Step 1: Implementation yaz
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
const ALGORITHM = 'aes-256-gcm';const IV_LENGTH = 12; // GCM önerisiconst 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
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 32MAIL_PAYLOAD_KEY=
# Communications log retention in days (default 90)LOG_RETENTION_DAYS=90- Step 4: Local .env’e geçici key oluştur
echo "MAIL_PAYLOAD_KEY=$(openssl rand -hex 32)" >> .envecho "LOG_RETENTION_DAYS=90" >> .env(Production deploy öncesi gerçek key set edilecek.)
- Step 5: Commit
git add lib/crypto/payload-cipher.ts lib/crypto/payload-cipher.test.ts .env.examplegit commit -m "feat(crypto): payload-cipher AES-256-GCM util ekle"Phase 1: Database Models & Migration
Bölüm başlığı “Phase 1: Database Models & Migration”Task 1.1: Prisma schema’ya 4 model ekle
Bölüm başlığı “Task 1.1: Prisma schema’ya 4 model 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
npx prisma formatnpx prisma validateBeklenen: The schema is valid.
- Step 3: Migration üret
npx prisma migrate dev --name add_communications_logsBeklenen: prisma/migrations/<timestamp>_add_communications_logs/migration.sql oluşur, DB’ye uygulanır.
- Step 4: Migration SQL’ini gözle kontrol et
ls -la prisma/migrations/ | tail -3cat prisma/migrations/*_add_communications_logs/migration.sql | head -80Bekleneni doğrula: CREATE TABLE "EmailLog", CREATE TABLE "MailPayload", FK constraints, index’ler.
- Step 5: Prisma client regenerate
npx prisma generate- Step 6: Commit
git add prisma/schema.prisma prisma/migrations/git commit -m "feat(db): EmailLog/SmsLog/MailPayload/SmsPayload modelleri ekle"Task 1.2: DB write smoke test
Bölüm başlığı “Task 1.2: DB write smoke test”Files:
-
Create:
lib/_db-smoke.test.ts(geçici) -
Step 1: Smoke test yaz
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
npm test -- "lib/_db-smoke.test.ts"Beklenen: 1 pass. Eğer fail ederse migration uygulanmamış demektir.
- Step 3: Smoke test’i sil
rm lib/_db-smoke.test.ts- Step 4: Commit (boş — değişiklik yok, atla)
Phase 2: Mail Service Integration
Bölüm başlığı “Phase 2: Mail Service Integration”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
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 overrideprocess.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
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.
Task 2.2: sendMail integration — implementation
Bölüm başlığı “Task 2.2: sendMail integration — implementation”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
npm test -- "lib/mail/service.test.ts"Beklenen: 2 pass.
- Step 3: Commit
git add lib/mail/service.ts lib/mail/service.test.tsgit commit -m "feat(mail): sendMail içine EmailLog + MailPayload yazımı"Task 2.3: EmailQueue emailLogId taşıması
Bölüm başlığı “Task 2.3: EmailQueue emailLogId taşıması”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
npx tsc --noEmitBeklenen: hata yok.
- Step 3: Commit
git add lib/mail/queue.tsgit commit -m "feat(mail): EmailQueue emailLogId taşıma + status sync"Phase 3: SMS Service Integration
Bölüm başlığı “Phase 3: SMS Service Integration”Task 3.1: sendSms log yazımı — failing test
Bölüm başlığı “Task 3.1: sendSms log yazımı — failing test”Files:
-
Create:
lib/sms/service.test.ts -
Step 1: Test yaz
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
npm test -- "lib/sms/service.test.ts"Task 3.2: sendSms implementation
Bölüm başlığı “Task 3.2: sendSms implementation”Files:
-
Modify:
lib/sms/service.ts -
Step 1: service.ts’i güncelle
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
npm test -- "lib/sms/service.test.ts"Beklenen: 1 pass.
- Step 3: Commit
git add lib/sms/service.ts lib/sms/service.test.tsgit commit -m "feat(sms): sendSms içine SmsLog + SmsPayload yazımı"Phase 4: Audit Log Extensions
Bölüm başlığı “Phase 4: Audit Log Extensions”Task 4.1: audit-actions module — failing test
Bölüm başlığı “Task 4.1: audit-actions module — failing test”Files:
-
Create:
lib/audit-actions.test.ts -
Step 1: Test yaz
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
npm test -- "lib/audit-actions.test.ts"Task 4.2: audit-actions implementation
Bölüm başlığı “Task 4.2: audit-actions implementation”Files:
-
Create:
lib/audit-actions.ts -
Step 1: Implementation yaz
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
npm test -- "lib/audit-actions.test.ts"- Step 3: Commit
git add lib/audit-actions.ts lib/audit-actions.test.tsgit commit -m "feat(audit): communications/credentials audit action constants + dedup"Phase 5: Communications API Endpoints
Bölüm başlığı “Phase 5: Communications API Endpoints”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
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:
getAdminSessionmevcut değilse engineer önceapp/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’larapp/api/admin/users/route.tsiçinde mevcut.
- Step 2: Manuel curl testi
Geliştirme sunucusunu başlat:
npm run devAdmin oturumu cookie’siyle test et:
curl -s "http://49.12.188.137:3077/api/admin/communications/emails?template=magic-link" \ -H "Cookie: $(cat /tmp/admin-cookie.txt)" | head -50Beklenen: JSON { items: [...], total, nextCursor }.
- Step 3: Commit
git add app/api/admin/communications/emails/route.tsgit 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
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
curl -s "http://49.12.188.137:3077/api/admin/communications/emails/1" -H "Cookie: ..." | jq- Step 3: Commit
git add app/api/admin/communications/emails/[id]/route.tsgit 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
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
git add app/api/admin/communications/emails/[id]/preview/route.tsgit commit -m "feat(api): emails preview endpoint (CSP-locked HTML)"Task 5.4: GET …/[id]/payload — decrypt + audit
Bölüm başlığı “Task 5.4: GET …/[id]/payload — decrypt + audit”Files:
-
Create:
app/api/admin/communications/emails/[id]/payload/route.ts -
Step 1: Implementation
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
git add app/api/admin/communications/emails/[id]/payload/route.tsgit commit -m "feat(api): emails payload reveal (decrypt + audit)"Task 5.5: POST …/[id]/resend
Bölüm başlığı “Task 5.5: POST …/[id]/resend”Files:
-
Create:
app/api/admin/communications/emails/[id]/resend/route.ts -
Step 1: Implementation
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
git add app/api/admin/communications/emails/[id]/resend/route.tsgit commit -m "feat(api): emails resend endpoint (zorunlu reason + audit chain)"Task 5.6: GET …/export — CSV stream
Bölüm başlığı “Task 5.6: GET …/export — CSV stream”Files:
-
Create:
app/api/admin/communications/emails/export/route.ts -
Step 1: Implementation
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
git add app/api/admin/communications/emails/export/route.tsgit commit -m "feat(api): emails CSV export"Task 5.7-5.11: SMS endpoint’leri
Bölüm başlığı “Task 5.7-5.11: SMS endpoint’leri”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, actionSMS_LIST_VIEW, select alanları SMS şeması (body, twilioSid, fromNumber)app/api/admin/communications/sms/[id]/route.ts— Task 5.2 paraleli, actionSMS_DETAIL_VIEW/SMS_DELETEapp/api/admin/communications/sms/[id]/payload/route.ts— Task 5.4 paraleli, actionSMS_PAYLOAD_REVEAL,prisma.smsPayloadapp/api/admin/communications/sms/[id]/resend/route.ts— Task 5.5 paraleli,sendSmsçağırır, actionSMS_RESENDapp/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"
Phase 6: Credentials API Endpoints
Bölüm başlığı “Phase 6: Credentials API Endpoints”Task 6.1: GET /admin/credentials/portals — list
Bölüm başlığı “Task 6.1: GET /admin/credentials/portals — list”Files:
-
Create:
app/api/admin/credentials/portals/route.ts -
Step 1: Implementation
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
git add app/api/admin/credentials/portals/route.tsgit 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
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
git add app/api/admin/credentials/portals/[subdomain]/route.tsgit 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
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
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ı:
psql -d ecu -c "SELECT action, status FROM \"AdminAuditLog\" WHERE action = 'credential.reveal' ORDER BY id DESC LIMIT 1;"- Step 3: Commit
git add app/api/admin/credentials/reveal/route.tsgit commit -m "feat(api): credential reveal endpoint (audit + field whitelist)"Task 6.4: by-type endpoint’leri
Bölüm başlığı “Task 6.4: by-type endpoint’leri”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
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 ORcustomerEmail, detailstype:customers. -
Step 3: mail/route.ts — select:
id, subdomain, mailUser, createdAt, search ORmailUser, detailstype:mail. Where filter:mailUser != null. -
Step 4: api/route.ts — select:
id, subdomain, apiTokenId, createdAt, search OR yalnızca subdomain, detailstype:api. Where:apiTokenId != null. -
Step 5: Commit
git add app/api/admin/credentials/by-type/git commit -m "feat(api): credentials by-type endpoint'leri (admins/customers/mail/api)"Phase 7: Communications Admin UI
Bölüm başlığı “Phase 7: Communications Admin UI”Bu fazda mevcut admin layout primitive’lerini (
components/admin/AdminPanelContent.tsx, AdminTicketList tablosu pattern’i, AdminInvoiceList drawer pattern’i) takip et. Stil içinapp/admin-theme.css’teki sınıflar kullanılır.
Task 7.1: communications/layout.tsx + tabs
Bölüm başlığı “Task 7.1: communications/layout.tsx + tabs”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.tsximport { 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.tsximport { 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
'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
git add app/[locale]/admin/\(panel\)/communications/ components/admin/communications/CommunicationsTabs.tsxgit commit -m "feat(ui): communications layout + tabs"Task 7.2: Email log filters component
Bölüm başlığı “Task 7.2: Email log filters component”Files:
-
Create:
components/admin/communications/EmailLogFilters.tsx -
Step 1: Implementation
'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
git add components/admin/communications/EmailLogFilters.tsxgit commit -m "feat(ui): EmailLogFilters component"Task 7.3: Email log table + page
Bölüm başlığı “Task 7.3: Email log table + page”Files:
-
Create:
components/admin/communications/EmailLogTable.tsx -
Create:
app/[locale]/admin/(panel)/communications/emails/page.tsx -
Step 1: 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.tsximport { 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
git add components/admin/communications/EmailLogTable.tsx app/[locale]/admin/\(panel\)/communications/emails/page.tsxgit commit -m "feat(ui): emails liste sayfası + tablo"Task 7.4: Email detail drawer (4 tab)
Bölüm başlığı “Task 7.4: Email detail drawer (4 tab)”Files:
-
Create:
components/admin/communications/EmailDetailDrawer.tsx -
Create:
components/admin/communications/EmailRenderedTab.tsx -
Create:
components/admin/communications/ResendDialog.tsx -
Step 1: 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
'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
'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
git add components/admin/communications/EmailDetailDrawer.tsx components/admin/communications/EmailRenderedTab.tsx components/admin/communications/ResendDialog.tsxgit commit -m "feat(ui): email detail drawer + sandboxed iframe + reveal + resend"Task 7.5: SMS UI (paralel)
Bölüm başlığı “Task 7.5: SMS UI (paralel)”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.tsx —
EmailLogTablepaterni, kolonlar: status, template, recipient, body preview (60 char), Twilio SID, time.SmsDetailDrawerimport et. -
Step 2: SmsDetailDrawer.tsx —
EmailDetailDrawerpaterni, tab’lar:body | twilio | payload. Body sandboxed iframe yerine<pre>(zaten text). Twilio tab’ıdata.twilioSidve 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
git add components/admin/communications/Sms* app/[locale]/admin/\(panel\)/communications/sms/git commit -m "feat(ui): SMS log liste + detail drawer"Task 7.6: Standalone email detay sayfası
Bölüm başlığı “Task 7.6: Standalone email detay sayfası”Files:
-
Create:
app/[locale]/admin/(panel)/communications/emails/[id]/page.tsx -
Step 1: Implementation
// app/[locale]/admin/(panel)/communications/emails/[id]/page.tsximport { 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
git add app/[locale]/admin/\(panel\)/communications/emails/[id]/git commit -m "feat(ui): standalone email detail deep-link sayfası"Phase 8: Credentials Admin UI
Bölüm başlığı “Phase 8: Credentials Admin UI”Task 8.1: credentials/layout.tsx + tabs
Bölüm başlığı “Task 8.1: credentials/layout.tsx + tabs”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.tsximport { 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.tsximport { 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
'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
git add app/[locale]/admin/\(panel\)/credentials/layout.tsx app/[locale]/admin/\(panel\)/credentials/page.tsx components/admin/credentials/CredentialsTabs.tsxgit commit -m "feat(ui): credentials layout + tabs (by-portal/by-type)"Task 8.2: Portal list page + table
Bölüm başlığı “Task 8.2: Portal list page + table”Files:
-
Create:
components/admin/credentials/PortalListTable.tsx -
Create:
app/[locale]/admin/(panel)/credentials/by-portal/page.tsx -
Step 1: 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.tsximport { 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
git add components/admin/credentials/PortalListTable.tsx app/[locale]/admin/\(panel\)/credentials/by-portal/page.tsxgit 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
'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
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
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.tsximport { 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:
psql -d ecu -c "SELECT action, target, details FROM \"AdminAuditLog\" WHERE action LIKE 'credential.%' ORDER BY id DESC LIMIT 5;"- Step 6: Commit
git add components/admin/credentials/ app/[locale]/admin/\(panel\)/credentials/by-portal/[subdomain]/git commit -m "feat(ui): portal detail + credential blocks + reveal/copy"Task 8.4: by-type sayfaları
Bölüm başlığı “Task 8.4: by-type sayfaları”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
'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.tsximport { 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
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)"Phase 9: Retention Cron
Bölüm başlığı “Phase 9: Retention Cron”Task 9.1: log-retention cron endpoint
Bölüm başlığı “Task 9.1: log-retention cron endpoint”Files:
-
Create:
app/api/cron/log-retention/route.ts -
Step 1: Implementation
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
# locally test against dev DB — geçici eski kayıt oluşturpsql -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ğırcurl -s -X POST http://49.12.188.137:3077/api/cron/log-retention \ -H "Authorization: Bearer $CRON_SECRET" | jqpsql -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>&1Bu adım deploy zamanı yapılır, plan’da sadece referans.
- Step 4: Commit
git add app/api/cron/log-retention/route.tsgit commit -m "feat(cron): log retention 90 gün cleanup endpoint"Phase 10: Sidebar & Audit UI Integration
Bölüm başlığı “Phase 10: Sidebar & Audit UI Integration”Task 10.1: AdminSidebar nav item’ları
Bölüm başlığı “Task 10.1: AdminSidebar nav item’ları”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
git add components/admin/AdminSidebar.tsxgit commit -m "feat(ui): sidebar Communications + Credentials nav grupları"Task 10.2: Audit log filter chip’leri (opsiyonel)
Bölüm başlığı “Task 10.2: Audit log filter chip’leri (opsiyonel)”Files:
-
Modify: mevcut
app/[locale]/admin/(panel)/security/page.tsxveya 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(actionLIKE 'communications.%')Credentials(actionLIKE 'credential.%')
Eğer mevcut UI yapısı farklıysa, engineer’a kalmış — bu task düşük öncelikli.
- Step 2: Commit
git add app/[locale]/admin/\(panel\)/security/git commit -m "feat(ui): audit log filter'a Communications/Credentials kategorileri"Phase 11: (Optional) Call Site Enrichment
Bölüm başlığı “Phase 11: (Optional) Call Site Enrichment”Bu faz opsiyonel ve tamamen geriye dönük uyumlu. Ana feature çalışıyor; bu sadece log’larda
customerId/adminIdeksikliğ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.tsapp/api/admin/auth/forgot-password/send/route.tsapp/api/admin/auth/login/route.ts
-
Step 1: Her dosyada
sendMailçağrısınacontextekle
Örnek pattern:
// Önceconst { 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
git add app/api/admin/auth/git commit -m "chore(mail): admin auth call site'larında EmailLog context enrichment"Task 11.2-11.7: Diğer mail call site grupları
Bölüm başlığı “Task 11.2-11.7: Diğer mail call site grupları”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.ts→git commit -m "chore(mail): server action call site enrichment" - 11.7 Ticket notifications:
lib/ticket-notifications.ts→git commit -m "chore(mail): ticket notifications call site enrichment"
Task 11.8: SMS call site enrichment
Bölüm başlığı “Task 11.8: SMS call site enrichment”Files:
-
Modify:
app/api/admin/auth/sms-otp/{send,verify}/route.tsapp/api/customer/auth/sms-otp/{send,verify}/route.tsapp/api/contact/route.ts
-
Step 1: Her dosyada
sendSmsçağrısınacontextekle -
Step 2: Commit
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"Verification Checklist (final)
Bölüm başlığı “Verification Checklist (final)”Implementation tamamlandıktan sonra şu kontrolleri yap:
-
npm testtüm unit testler geçiyor -
npx tsc --noEmittype hata yok -
npm run buildbaşarılı (Next.js production build) - Manuel:
/admin/communications/emailslistesinde 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-portalportal 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.*vecredential.*aksiyonları görünüyor;list_viewgünde 1 kez kayıtlı
Notes for Executing Agent
Bölüm başlığı “Notes for Executing Agent”- 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ıflarapp/admin-theme.cssiçinde tanımlı; eksik olanları engineer eklemeli. getAdminSession: bu plan bu helper’ın varlığını varsayar. Kod tabanındaapp/api/admin/users/route.tsveya benzeri rotalarda gerçek pattern bulunup adapt edilmeli (iron-session kullanılıyor).@/generated/prismaimport path: mevcut codebase’de Prisma client alias@/generated/prismaveya@prisma/clientolarak kullanılıyor olabilir;lib/prisma.ts’deki re-export’u takip et.- Test izolasyonu:
node:testparalel çalıştığında DB’ye yazıyorsa unique recipient ile filter ettim, ama daha güvenli için her test kendibeforeEachile 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.