diff --git a/app/(dashboard)/lists/[id]/page.tsx b/app/(dashboard)/lists/[id]/page.tsx index c7287d2..2c8c5e7 100644 --- a/app/(dashboard)/lists/[id]/page.tsx +++ b/app/(dashboard)/lists/[id]/page.tsx @@ -24,6 +24,12 @@ import { DialogTitle, DialogTrigger, } from '@/components/ui/dialog' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' import { useToast } from '@/components/ui/use-toast' interface ListInfo { @@ -81,8 +87,64 @@ export default function ListDetailPage() { const [addFirstName, setAddFirstName] = useState('') const [addLastName, setAddLastName] = useState('') const [addSaving, setAddSaving] = useState(false) + + const [gdprDeleteContact, setGdprDeleteContact] = useState(null) + const [gdprConfirmEmail, setGdprConfirmEmail] = useState('') + const [gdprDeleting, setGdprDeleting] = useState(false) + const { toast } = useToast() + async function handleGdprExport(contact: Contact) { + try { + const res = await fetch(`/api/internal/contacts/${contact.id}/gdpr-export`) + if (!res.ok) { + const data = await res.json().catch(() => ({})) + toast({ title: data.error || 'Export failed', variant: 'destructive' }) + return + } + const blob = await res.blob() + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `contact-${contact.id}-export.json` + document.body.appendChild(a) + a.click() + a.remove() + URL.revokeObjectURL(url) + toast({ title: 'Export downloaded' }) + } catch { + toast({ title: 'Export failed', variant: 'destructive' }) + } + } + + async function handleGdprDelete() { + if (!gdprDeleteContact) return + if (gdprConfirmEmail.trim().toLowerCase() !== gdprDeleteContact.email.toLowerCase()) { + toast({ title: 'Email confirmation does not match', variant: 'destructive' }) + return + } + setGdprDeleting(true) + try { + const url = `/api/internal/contacts/${gdprDeleteContact.id}/gdpr-delete?confirm=${encodeURIComponent(gdprDeleteContact.email)}` + const res = await fetch(url, { method: 'DELETE' }) + if (!res.ok) { + const data = await res.json().catch(() => ({})) + toast({ title: data.error || 'Delete failed', variant: 'destructive' }) + return + } + toast({ title: 'Contact and all related data deleted' }) + setGdprDeleteContact(null) + setGdprConfirmEmail('') + fetchContacts() + const listRes = await fetch(`/api/internal/lists/${listId}`) + if (listRes.ok) setListInfo(await listRes.json()) + } catch { + toast({ title: 'Delete failed', variant: 'destructive' }) + } finally { + setGdprDeleting(false) + } + } + async function handleAddContact(e: React.FormEvent) { e.preventDefault() if (!addEmail.trim()) return @@ -365,18 +427,19 @@ export default function ListDetailPage() { First Name Last Name Created + {contactsLoading ? ( - + Loading contacts... ) : contacts.length === 0 ? ( - + {search ? 'No contacts match your search.' : `No ${tab} contacts in this list.`} @@ -391,6 +454,30 @@ export default function ListDetailPage() { {format(new Date(contact.createdAt), 'MMM d, yyyy')} + + + + + + + handleGdprExport(contact)}> + Export GDPR data + + { + setGdprDeleteContact(contact) + setGdprConfirmEmail('') + }} + > + Delete (GDPR) + + + + )) )} @@ -430,6 +517,51 @@ export default function ListDetailPage() { ))} + + { + if (!open) { + setGdprDeleteContact(null) + setGdprConfirmEmail('') + } + }} + > + + + Hard delete contact (GDPR) + +
+

+ This will permanently delete {gdprDeleteContact?.email} and all associated send and engagement records. This cannot be undone. +

+

+ Type the email below to confirm. +

+ setGdprConfirmEmail(e.target.value)} + /> +
+ + + + +
+
) } diff --git a/app/(dashboard)/settings/api-keys/page.tsx b/app/(dashboard)/settings/api-keys/page.tsx index cb30919..17d6e58 100644 --- a/app/(dashboard)/settings/api-keys/page.tsx +++ b/app/(dashboard)/settings/api-keys/page.tsx @@ -28,6 +28,7 @@ interface ApiKey { id: string name: string lastUsedAt: string | null + rateLimitPerMinute: number createdAt: string } @@ -39,6 +40,11 @@ export default function ApiKeysPage() { const [name, setName] = useState("") const [saving, setSaving] = useState(false) const [newKey, setNewKey] = useState(null) + + const [editingId, setEditingId] = useState(null) + const [editLimit, setEditLimit] = useState("") + const [editSaving, setEditSaving] = useState(false) + const { toast } = useToast() const fetchKeys = async () => { @@ -94,6 +100,35 @@ export default function ApiKeysPage() { } } + const handleSaveLimit = async () => { + if (!editingId) return + const value = parseInt(editLimit, 10) + if (Number.isNaN(value) || value < 1) { + toast({ title: "Enter a number greater than 0", variant: "destructive" }) + return + } + setEditSaving(true) + try { + const res = await fetch(`/api/internal/api-keys/${editingId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rateLimitPerMinute: value }), + }) + if (!res.ok) { + const data = await res.json().catch(() => ({})) + toast({ title: data.error || "Failed to update rate limit", variant: "destructive" }) + return + } + toast({ title: "Rate limit updated" }) + setEditingId(null) + fetchKeys() + } catch { + toast({ title: "Failed to update rate limit", variant: "destructive" }) + } finally { + setEditSaving(false) + } + } + const copyToClipboard = (text: string) => { navigator.clipboard.writeText(text) toast({ title: "Copied to clipboard" }) @@ -192,15 +227,28 @@ export default function ApiKeysPage() { Name + Requests / min Last Used Created - + {keys.map((key) => ( {key.name} + + + {key.lastUsedAt ? format(new Date(key.lastUsedAt), "MMM d, yyyy HH:mm") : "Never"} @@ -221,6 +269,37 @@ export default function ApiKeysPage() { )} + {/* Edit rate limit dialog */} + { if (!open) setEditingId(null) }}> + + + Edit Rate Limit + +
+
+ + setEditLimit(e.target.value)} + /> +

+ Token bucket capacity, refills at this rate per minute. The bucket is reset to full when you save. +

+
+
+ + + + +
+
+ {/* Delete confirmation dialog */} { if (!open) setDeleteId(null) }}> diff --git a/app/(dashboard)/settings/audit-log/page.tsx b/app/(dashboard)/settings/audit-log/page.tsx new file mode 100644 index 0000000..45159e9 --- /dev/null +++ b/app/(dashboard)/settings/audit-log/page.tsx @@ -0,0 +1,237 @@ +"use client" + +import { Fragment, useEffect, useState } from "react" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Badge } from "@/components/ui/badge" +import { Skeleton } from "@/components/ui/skeleton" +import { format } from "date-fns" + +interface AuditLog { + id: string + actorType: "user" | "api_key" | "system" + actorId: string | null + actorLabel: string | null + action: string + resourceType: string | null + resourceId: string | null + metadata: Record | null + ipAddress: string | null + userAgent: string | null + createdAt: string +} + +interface Meta { + page: number + limit: number + total: number +} + +const ACTOR_TYPES = ["", "user", "api_key", "system"] as const +const RESOURCE_TYPES = ["", "list", "contact", "campaign", "provider", "api_key"] as const + +export default function AuditLogPage() { + const [logs, setLogs] = useState([]) + const [meta, setMeta] = useState({ page: 1, limit: 50, total: 0 }) + const [loading, setLoading] = useState(true) + const [expandedId, setExpandedId] = useState(null) + + const [actorType, setActorType] = useState("") + const [resourceType, setResourceType] = useState("") + const [actionFilter, setActionFilter] = useState("") + const [page, setPage] = useState(1) + + useEffect(() => { + let cancelled = false + async function fetchLogs() { + setLoading(true) + try { + const params = new URLSearchParams({ page: String(page), limit: "50" }) + if (actorType) params.set("actorType", actorType) + if (resourceType) params.set("resourceType", resourceType) + if (actionFilter) params.set("action", actionFilter) + const res = await fetch(`/api/internal/audit-logs?${params.toString()}`) + if (!res.ok || cancelled) return + const json = await res.json() + if (!cancelled) { + setLogs(json.data || []) + setMeta(json.meta || { page: 1, limit: 50, total: 0 }) + } + } finally { + if (!cancelled) setLoading(false) + } + } + fetchLogs() + return () => { cancelled = true } + }, [page, actorType, resourceType, actionFilter]) + + const totalPages = Math.max(1, Math.ceil(meta.total / meta.limit)) + + return ( +
+
+

Audit Log

+

+ Append-only record of every state-changing action. +

+
+ +
+
+ + +
+
+ + +
+
+ + setActionFilter(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && setPage(1)} + className="w-56 h-9" + /> +
+ {(actorType || resourceType || actionFilter) && ( + + )} +
+ + {loading ? ( + + ) : logs.length === 0 ? ( +
+ No audit log entries match the current filters. +
+ ) : ( +
+ + + + When + Actor + Action + Resource + + + + + {logs.map((log) => ( + + + + {format(new Date(log.createdAt), "MMM d, yyyy HH:mm:ss")} + + +
+ + {log.actorType} + + {log.actorLabel ?? "system"} +
+
+ {log.action} + + {log.resourceType + ? `${log.resourceType}${log.resourceId ? ` / ${log.resourceId.slice(0, 8)}` : ""}` + : "-"} + + + + +
+ {expandedId === log.id && ( + + +
{JSON.stringify({
+                          actorId: log.actorId,
+                          resourceId: log.resourceId,
+                          ipAddress: log.ipAddress,
+                          userAgent: log.userAgent,
+                          metadata: log.metadata,
+                        }, null, 2)}
+
+
+ )} +
+ ))} +
+
+
+ )} + +
+ + {meta.total === 0 ? "No entries" : `${meta.total} entr${meta.total === 1 ? "y" : "ies"} total`} + +
+ + Page {page} of {totalPages} + +
+
+
+ ) +} diff --git a/app/api/internal/api-keys/[id]/route.ts b/app/api/internal/api-keys/[id]/route.ts index c5f3004..5dd1311 100644 --- a/app/api/internal/api-keys/[id]/route.ts +++ b/app/api/internal/api-keys/[id]/route.ts @@ -2,19 +2,80 @@ import { NextRequest, NextResponse } from 'next/server' import { db } from '@/lib/db' import { apiKeys } from '@/lib/db/schema' import { eq } from 'drizzle-orm' +import { z } from 'zod' +import { auditFromSession, logAudit } from '@/lib/audit' + +const updateApiKeySchema = z.object({ + rateLimitPerMinute: z.number().int().min(1).max(100000), +}) export async function DELETE( - _req: NextRequest, + req: NextRequest, { params }: { params: { id: string } } ) { const [deleted] = await db .delete(apiKeys) .where(eq(apiKeys.id, params.id)) - .returning({ id: apiKeys.id }) + .returning({ id: apiKeys.id, name: apiKeys.name }) if (!deleted) { return NextResponse.json({ error: 'API key not found' }, { status: 404 }) } + await logAudit( + await auditFromSession(req), + 'api_key.delete', + { type: 'api_key', id: deleted.id }, + { name: deleted.name }, + ) + return NextResponse.json({ success: true }) } + +export async function PATCH( + req: NextRequest, + { params }: { params: { id: string } } +) { + let body: unknown + try { + body = await req.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + const parsed = updateApiKeySchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.errors.map((e) => e.message).join(', ') }, + { status: 400 }, + ) + } + + const [updated] = await db + .update(apiKeys) + .set({ + rateLimitPerMinute: parsed.data.rateLimitPerMinute, + // Reset the bucket capacity so the new limit takes effect immediately. + rateLimitTokens: parsed.data.rateLimitPerMinute, + rateLimitUpdatedAt: new Date(), + }) + .where(eq(apiKeys.id, params.id)) + .returning({ + id: apiKeys.id, + name: apiKeys.name, + rateLimitPerMinute: apiKeys.rateLimitPerMinute, + }) + + if (!updated) { + return NextResponse.json({ error: 'API key not found' }, { status: 404 }) + } + + await logAudit( + await auditFromSession(req), + 'api_key.update_rate_limit', + { type: 'api_key', id: updated.id }, + { name: updated.name, rateLimitPerMinute: updated.rateLimitPerMinute }, + ) + + return NextResponse.json(updated) +} diff --git a/app/api/internal/api-keys/route.ts b/app/api/internal/api-keys/route.ts index 1f50df8..60d85c6 100644 --- a/app/api/internal/api-keys/route.ts +++ b/app/api/internal/api-keys/route.ts @@ -4,6 +4,7 @@ import { apiKeys } from '@/lib/db/schema' import { nanoid } from 'nanoid' import bcrypt from 'bcryptjs' import { createApiKeySchema } from '@/lib/validations/api-keys' +import { auditFromSession, logAudit } from '@/lib/audit' export async function GET() { const keys = await db @@ -11,6 +12,7 @@ export async function GET() { id: apiKeys.id, name: apiKeys.name, lastUsedAt: apiKeys.lastUsedAt, + rateLimitPerMinute: apiKeys.rateLimitPerMinute, createdAt: apiKeys.createdAt, }) .from(apiKeys) @@ -45,8 +47,16 @@ export async function POST(req: NextRequest) { .returning({ id: apiKeys.id, name: apiKeys.name, + rateLimitPerMinute: apiKeys.rateLimitPerMinute, createdAt: apiKeys.createdAt, }) + await logAudit( + await auditFromSession(req), + 'api_key.create', + { type: 'api_key', id: created.id }, + { name: created.name }, + ) + return NextResponse.json({ ...created, key: rawKey }, { status: 201 }) } diff --git a/app/api/internal/audit-logs/route.ts b/app/api/internal/audit-logs/route.ts new file mode 100644 index 0000000..50483d0 --- /dev/null +++ b/app/api/internal/audit-logs/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from 'next/server' +import { db } from '@/lib/db' +import { auditLogs } from '@/lib/db/schema' +import { and, desc, eq, gte, lte, count, SQL } from 'drizzle-orm' + +export async function GET(req: NextRequest) { + const { searchParams } = req.nextUrl + + const page = Math.max(1, parseInt(searchParams.get('page') ?? '1', 10)) + const rawLimit = parseInt(searchParams.get('limit') ?? '50', 10) + const limit = Math.min(Math.max(1, rawLimit), 200) + const offset = (page - 1) * limit + + const actorType = searchParams.get('actorType') + const resourceType = searchParams.get('resourceType') + const resourceId = searchParams.get('resourceId') + const action = searchParams.get('action') + const fromStr = searchParams.get('from') + const toStr = searchParams.get('to') + + const conditions: SQL[] = [] + if (actorType) conditions.push(eq(auditLogs.actorType, actorType)) + if (resourceType) conditions.push(eq(auditLogs.resourceType, resourceType)) + if (resourceId) conditions.push(eq(auditLogs.resourceId, resourceId)) + if (action) conditions.push(eq(auditLogs.action, action)) + if (fromStr) { + const from = new Date(fromStr) + if (!Number.isNaN(from.getTime())) conditions.push(gte(auditLogs.createdAt, from)) + } + if (toStr) { + const to = new Date(toStr) + if (!Number.isNaN(to.getTime())) conditions.push(lte(auditLogs.createdAt, to)) + } + + const where = conditions.length > 0 ? and(...conditions) : undefined + + const [{ total }] = await db + .select({ total: count() }) + .from(auditLogs) + .where(where) + + const data = await db + .select() + .from(auditLogs) + .where(where) + .orderBy(desc(auditLogs.createdAt)) + .limit(limit) + .offset(offset) + + return NextResponse.json({ + data, + meta: { page, limit, total }, + }) +} diff --git a/app/api/internal/campaigns/[id]/cancel/route.ts b/app/api/internal/campaigns/[id]/cancel/route.ts index 86333f2..d8aa0b9 100644 --- a/app/api/internal/campaigns/[id]/cancel/route.ts +++ b/app/api/internal/campaigns/[id]/cancel/route.ts @@ -2,9 +2,10 @@ import { NextRequest, NextResponse } from 'next/server' import { db } from '@/lib/db' import { campaigns } from '@/lib/db/schema' import { eq } from 'drizzle-orm' +import { auditFromSession, logAudit } from '@/lib/audit' export async function POST( - _req: NextRequest, + req: NextRequest, { params }: { params: { id: string } } ) { const [updated] = await db @@ -21,5 +22,12 @@ export async function POST( return NextResponse.json({ error: 'Campaign not found' }, { status: 404 }) } + await logAudit( + await auditFromSession(req), + 'campaign.cancel', + { type: 'campaign', id: updated.id }, + { name: updated.name }, + ) + return NextResponse.json({ success: true }) } diff --git a/app/api/internal/campaigns/[id]/route.ts b/app/api/internal/campaigns/[id]/route.ts index f3382fe..bd332ce 100644 --- a/app/api/internal/campaigns/[id]/route.ts +++ b/app/api/internal/campaigns/[id]/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import { db } from '@/lib/db' import { campaigns } from '@/lib/db/schema' import { eq } from 'drizzle-orm' +import { auditFromSession, logAudit } from '@/lib/audit' export async function GET( _req: NextRequest, @@ -56,5 +57,12 @@ export async function PATCH( return NextResponse.json({ error: 'Campaign not found' }, { status: 404 }) } + await logAudit( + await auditFromSession(req), + 'campaign.update', + { type: 'campaign', id: updated.id }, + { fields: Object.keys(updateData).filter((k) => k !== 'updatedAt') }, + ) + return NextResponse.json(updated) } diff --git a/app/api/internal/campaigns/[id]/send/route.ts b/app/api/internal/campaigns/[id]/send/route.ts index 1da8d24..a30af4d 100644 --- a/app/api/internal/campaigns/[id]/send/route.ts +++ b/app/api/internal/campaigns/[id]/send/route.ts @@ -4,6 +4,7 @@ import { campaigns, campaignSends, contacts } from '@/lib/db/schema' import { eq, and } from 'drizzle-orm' import { getQueue, JOBS } from '@/lib/queue' import { logger, trackEvent, trackError } from '@/lib/logger' +import { auditFromSession, logAudit } from '@/lib/audit' export async function POST( req: NextRequest, @@ -147,5 +148,16 @@ export async function POST( durationMs, }) + await logAudit( + await auditFromSession(req), + 'campaign.send', + { type: 'campaign', id: campaign.id }, + { + totalRecipients: contactList.length, + scheduledAt: scheduledAt ?? null, + status: newStatus, + }, + ) + return NextResponse.json({ queued: sends.length }) } diff --git a/app/api/internal/campaigns/[id]/test-send/route.ts b/app/api/internal/campaigns/[id]/test-send/route.ts index b550772..5c024b0 100644 --- a/app/api/internal/campaigns/[id]/test-send/route.ts +++ b/app/api/internal/campaigns/[id]/test-send/route.ts @@ -5,6 +5,7 @@ import { eq } from 'drizzle-orm' import { createProviderAdapter } from '@/lib/providers/factory' import { renderTemplate, renderPlainText } from '@/lib/renderer' import { logger, trackEvent, trackError } from '@/lib/logger' +import { auditFromSession, logAudit } from '@/lib/audit' export async function POST( req: NextRequest, @@ -182,6 +183,13 @@ export async function POST( ) } + await logAudit( + await auditFromSession(req), + 'campaign.test_send', + { type: 'campaign', id: campaign.id }, + { recipients: sent, failedCount: failed.length }, + ) + return NextResponse.json({ success: true, sent, failed }) } catch (err) { const durationMs = Date.now() - startTime diff --git a/app/api/internal/campaigns/route.ts b/app/api/internal/campaigns/route.ts index fd03300..b86d646 100644 --- a/app/api/internal/campaigns/route.ts +++ b/app/api/internal/campaigns/route.ts @@ -3,6 +3,7 @@ import { db } from '@/lib/db' import { campaigns, lists, emailProviders } from '@/lib/db/schema' import { eq, desc, sql } from 'drizzle-orm' import { z } from 'zod' +import { auditFromSession, logAudit } from '@/lib/audit' const createCampaignSchema = z.object({ name: z.string().min(1), @@ -73,5 +74,12 @@ export async function POST(req: NextRequest) { }) .returning() + await logAudit( + await auditFromSession(req), + 'campaign.create', + { type: 'campaign', id: created.id }, + { name: created.name, listId }, + ) + return NextResponse.json(created, { status: 201 }) } diff --git a/app/api/internal/contacts/[id]/gdpr-delete/route.ts b/app/api/internal/contacts/[id]/gdpr-delete/route.ts new file mode 100644 index 0000000..340f84c --- /dev/null +++ b/app/api/internal/contacts/[id]/gdpr-delete/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from 'next/server' +import { db } from '@/lib/db' +import { contacts } from '@/lib/db/schema' +import { eq } from 'drizzle-orm' +import { hardDeleteContact } from '@/lib/gdpr' +import { auditFromSession, logAudit } from '@/lib/audit' + +export async function DELETE( + req: NextRequest, + { params }: { params: { id: string } } +) { + const confirm = req.nextUrl.searchParams.get('confirm') + if (!confirm) { + return NextResponse.json( + { error: 'Pass ?confirm= to confirm the hard delete.' }, + { status: 400 }, + ) + } + + const [existing] = await db.select().from(contacts).where(eq(contacts.id, params.id)) + if (!existing) { + return NextResponse.json({ error: 'Contact not found' }, { status: 404 }) + } + + if (existing.email.toLowerCase() !== confirm.toLowerCase()) { + return NextResponse.json( + { error: 'Confirmation email does not match the contact on file.' }, + { status: 400 }, + ) + } + + const result = await hardDeleteContact(params.id) + if (!result) { + return NextResponse.json({ error: 'Contact not found' }, { status: 404 }) + } + + await logAudit( + await auditFromSession(req), + 'contact.gdpr_delete', + { type: 'contact', id: params.id }, + { + email: result.email, + listId: result.listId, + sendCount: result.sendCount, + eventCount: result.eventCount, + }, + ) + + return NextResponse.json({ + deleted: true, + sendCount: result.sendCount, + eventCount: result.eventCount, + }) +} diff --git a/app/api/internal/contacts/[id]/gdpr-export/route.ts b/app/api/internal/contacts/[id]/gdpr-export/route.ts new file mode 100644 index 0000000..ba67e77 --- /dev/null +++ b/app/api/internal/contacts/[id]/gdpr-export/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from 'next/server' +import { buildContactExport } from '@/lib/gdpr' +import { auditFromSession, logAudit } from '@/lib/audit' + +export async function GET( + req: NextRequest, + { params }: { params: { id: string } } +) { + const data = await buildContactExport(params.id) + if (!data) { + return NextResponse.json({ error: 'Contact not found' }, { status: 404 }) + } + + await logAudit( + await auditFromSession(req), + 'contact.gdpr_export', + { type: 'contact', id: params.id }, + { sendCount: data.sends.length, eventCount: data.events.length }, + ) + + return new NextResponse(JSON.stringify(data, null, 2), { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Content-Disposition': `attachment; filename="contact-${params.id}-export.json"`, + }, + }) +} diff --git a/app/api/internal/lists/[id]/contacts/route.ts b/app/api/internal/lists/[id]/contacts/route.ts index baa7bda..2ca9409 100644 --- a/app/api/internal/lists/[id]/contacts/route.ts +++ b/app/api/internal/lists/[id]/contacts/route.ts @@ -3,6 +3,7 @@ import { db } from '@/lib/db' import { contacts } from '@/lib/db/schema' import { eq, ilike, and, count, SQL } from 'drizzle-orm' import { createContactSchema } from '@/lib/validations/contacts' +import { auditFromSession, logAudit } from '@/lib/audit' export async function GET( req: NextRequest, @@ -78,6 +79,13 @@ export async function POST( }) .returning() + await logAudit( + await auditFromSession(req), + 'contact.create', + { type: 'contact', id: created.id }, + { listId: params.id, email: created.email }, + ) + return NextResponse.json(created, { status: 201 }) } catch (e: unknown) { const msg = e instanceof Error ? e.message : '' diff --git a/app/api/internal/lists/[id]/route.ts b/app/api/internal/lists/[id]/route.ts index 9aa19fd..4fae4ec 100644 --- a/app/api/internal/lists/[id]/route.ts +++ b/app/api/internal/lists/[id]/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import { db } from '@/lib/db' import { lists, contacts } from '@/lib/db/schema' import { eq, sql } from 'drizzle-orm' +import { auditFromSession, logAudit } from '@/lib/audit' export async function GET( _req: NextRequest, @@ -33,9 +34,20 @@ export async function GET( } export async function DELETE( - _req: NextRequest, + req: NextRequest, { params }: { params: { id: string } } ) { + const [list] = await db.select().from(lists).where(eq(lists.id, params.id)) await db.delete(lists).where(eq(lists.id, params.id)) + + if (list) { + await logAudit( + await auditFromSession(req), + 'list.delete', + { type: 'list', id: params.id }, + { name: list.name }, + ) + } + return NextResponse.json({ success: true }) } diff --git a/app/api/internal/lists/[id]/upload/confirm/route.ts b/app/api/internal/lists/[id]/upload/confirm/route.ts index 32efdbc..4f4c0bb 100644 --- a/app/api/internal/lists/[id]/upload/confirm/route.ts +++ b/app/api/internal/lists/[id]/upload/confirm/route.ts @@ -5,6 +5,7 @@ import { db } from '@/lib/db' import { contacts } from '@/lib/db/schema' import { sql } from 'drizzle-orm' import { uploadConfirmSchema } from '@/lib/validations/lists' +import { auditFromSession, logAudit } from '@/lib/audit' const s3 = new S3Client({ region: process.env.S3_REGION!, @@ -127,6 +128,13 @@ export async function POST( processed += batch.length } + await logAudit( + await auditFromSession(request), + 'contact.upsert_bulk', + { type: 'list', id: listId }, + { inserted: processed, skipped, source: 'upload' }, + ) + // We cannot distinguish inserts from updates without a returning clause diff, // so we report total processed as the combined count. Skipped rows had no email. return NextResponse.json({ diff --git a/app/api/internal/lists/route.ts b/app/api/internal/lists/route.ts index d2627ca..c70551e 100644 --- a/app/api/internal/lists/route.ts +++ b/app/api/internal/lists/route.ts @@ -3,6 +3,7 @@ import { db } from '@/lib/db' import { lists, contacts } from '@/lib/db/schema' import { eq, sql } from 'drizzle-orm' import { createListSchema } from '@/lib/validations/lists' +import { auditFromSession, logAudit } from '@/lib/audit' export async function GET() { const rows = await db @@ -47,5 +48,12 @@ export async function POST(req: NextRequest) { }) .returning() + await logAudit( + await auditFromSession(req), + 'list.create', + { type: 'list', id: created.id }, + { name: created.name }, + ) + return NextResponse.json(created, { status: 201 }) } diff --git a/app/api/internal/providers/[id]/route.ts b/app/api/internal/providers/[id]/route.ts index 47e0c46..ac84116 100644 --- a/app/api/internal/providers/[id]/route.ts +++ b/app/api/internal/providers/[id]/route.ts @@ -3,6 +3,7 @@ import { db } from '@/lib/db' import { emailProviders } from '@/lib/db/schema' import { eq } from 'drizzle-orm' import { decrypt } from '@/lib/encryption' +import { auditFromSession, logAudit } from '@/lib/audit' interface ProviderConfig { apiKey?: string @@ -44,11 +45,20 @@ function safeProviderView(provider: typeof emailProviders.$inferSelect) { } export async function DELETE( - _req: NextRequest, + req: NextRequest, { params }: { params: { id: string } } ) { try { + const [existing] = await db.select().from(emailProviders).where(eq(emailProviders.id, params.id)) await db.delete(emailProviders).where(eq(emailProviders.id, params.id)) + if (existing) { + await logAudit( + await auditFromSession(req), + 'provider.delete', + { type: 'provider', id: params.id }, + { name: existing.name, providerType: existing.type }, + ) + } return NextResponse.json({ success: true }) } catch (err) { const message = err instanceof Error ? err.message : 'Failed to delete provider' @@ -97,6 +107,13 @@ export async function PATCH( return NextResponse.json({ error: 'Provider not found' }, { status: 404 }) } + await logAudit( + await auditFromSession(req), + isDefault ? 'provider.set_default' : 'provider.unset_default', + { type: 'provider', id: updated.id }, + { name: updated.name }, + ) + return NextResponse.json(safeProviderView(updated)) } catch (err) { const message = err instanceof Error ? err.message : 'Failed to update provider' diff --git a/app/api/internal/providers/[id]/validate/route.ts b/app/api/internal/providers/[id]/validate/route.ts index 924a890..8dcab68 100644 --- a/app/api/internal/providers/[id]/validate/route.ts +++ b/app/api/internal/providers/[id]/validate/route.ts @@ -4,9 +4,10 @@ import { emailProviders } from '@/lib/db/schema' import { eq } from 'drizzle-orm' import { createProviderAdapter } from '@/lib/providers/factory' import { logger, trackEvent, trackError } from '@/lib/logger' +import { auditFromSession, logAudit } from '@/lib/audit' export async function POST( - _req: NextRequest, + req: NextRequest, { params }: { params: { id: string } } ) { const startTime = Date.now() @@ -36,6 +37,13 @@ export async function POST( durationMs, }) + await logAudit( + await auditFromSession(req), + 'provider.validate', + { type: 'provider', id: params.id }, + { valid, providerType: provider.type }, + ) + return NextResponse.json({ valid }) } catch (err) { const durationMs = Date.now() - startTime diff --git a/app/api/internal/providers/route.ts b/app/api/internal/providers/route.ts index 30e83f8..03e0d54 100644 --- a/app/api/internal/providers/route.ts +++ b/app/api/internal/providers/route.ts @@ -3,6 +3,7 @@ import { db } from '@/lib/db' import { emailProviders } from '@/lib/db/schema' import { decrypt, encrypt } from '@/lib/encryption' import { createProviderSchema } from '@/lib/validations/providers' +import { auditFromSession, logAudit } from '@/lib/audit' interface ProviderConfig { apiKey?: string @@ -101,6 +102,13 @@ export async function POST(req: NextRequest) { }) .returning() + await logAudit( + await auditFromSession(req), + 'provider.create', + { type: 'provider', id: created.id }, + { name: created.name, providerType: created.type }, + ) + return NextResponse.json(safeProviderView(created), { status: 201 }) } catch (err) { const message = err instanceof Error ? err.message : 'Failed to create provider' diff --git a/app/api/v1/campaigns/[id]/route.ts b/app/api/v1/campaigns/[id]/route.ts index b69df0e..f8e6849 100644 --- a/app/api/v1/campaigns/[id]/route.ts +++ b/app/api/v1/campaigns/[id]/route.ts @@ -2,42 +2,40 @@ import { NextRequest, NextResponse } from 'next/server' import { db } from '@/lib/db' import { campaigns, lists } from '@/lib/db/schema' import { eq, sql } from 'drizzle-orm' -import { authenticateApiKey } from '@/lib/api-auth' +import { withApiAuth } from '@/lib/api-auth' export async function GET( req: NextRequest, { params }: { params: { id: string } } ) { - if (!(await authenticateApiKey(req))) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + return withApiAuth(req, async () => { + const rows = await db + .select({ + id: campaigns.id, + name: campaigns.name, + subject: campaigns.subject, + fromName: campaigns.fromName, + fromEmail: campaigns.fromEmail, + listId: campaigns.listId, + status: campaigns.status, + scheduledAt: campaigns.scheduledAt, + sentAt: campaigns.sentAt, + totalRecipients: campaigns.totalRecipients, + createdAt: campaigns.createdAt, + updatedAt: campaigns.updatedAt, + listName: lists.name, + sent: sql`(SELECT CAST(COUNT(*) AS INT) FROM campaign_sends WHERE campaign_id = ${campaigns.id} AND status = 'sent')`, + opens: sql`(SELECT CAST(COUNT(DISTINCT campaign_send_id) AS INT) FROM campaign_events WHERE campaign_id = ${campaigns.id} AND type = 'open')`, + clicks: sql`(SELECT CAST(COUNT(DISTINCT campaign_send_id) AS INT) FROM campaign_events WHERE campaign_id = ${campaigns.id} AND type = 'click')`, + }) + .from(campaigns) + .leftJoin(lists, eq(campaigns.listId, lists.id)) + .where(eq(campaigns.id, params.id)) - const rows = await db - .select({ - id: campaigns.id, - name: campaigns.name, - subject: campaigns.subject, - fromName: campaigns.fromName, - fromEmail: campaigns.fromEmail, - listId: campaigns.listId, - status: campaigns.status, - scheduledAt: campaigns.scheduledAt, - sentAt: campaigns.sentAt, - totalRecipients: campaigns.totalRecipients, - createdAt: campaigns.createdAt, - updatedAt: campaigns.updatedAt, - listName: lists.name, - sent: sql`(SELECT CAST(COUNT(*) AS INT) FROM campaign_sends WHERE campaign_id = ${campaigns.id} AND status = 'sent')`, - opens: sql`(SELECT CAST(COUNT(DISTINCT campaign_send_id) AS INT) FROM campaign_events WHERE campaign_id = ${campaigns.id} AND type = 'open')`, - clicks: sql`(SELECT CAST(COUNT(DISTINCT campaign_send_id) AS INT) FROM campaign_events WHERE campaign_id = ${campaigns.id} AND type = 'click')`, - }) - .from(campaigns) - .leftJoin(lists, eq(campaigns.listId, lists.id)) - .where(eq(campaigns.id, params.id)) + if (!rows[0]) { + return NextResponse.json({ error: 'Campaign not found', data: null, meta: {} }, { status: 404 }) + } - if (!rows[0]) { - return NextResponse.json({ error: 'Campaign not found', data: null, meta: {} }, { status: 404 }) - } - - return NextResponse.json({ data: rows[0], meta: {}, error: null }) + return NextResponse.json({ data: rows[0], meta: {}, error: null }) + }) } diff --git a/app/api/v1/campaigns/[id]/stats/route.ts b/app/api/v1/campaigns/[id]/stats/route.ts index 525f47b..acf5283 100644 --- a/app/api/v1/campaigns/[id]/stats/route.ts +++ b/app/api/v1/campaigns/[id]/stats/route.ts @@ -2,84 +2,82 @@ import { NextRequest, NextResponse } from 'next/server' import { db } from '@/lib/db' import { campaignSends, campaignEvents } from '@/lib/db/schema' import { eq, and, sql } from 'drizzle-orm' -import { authenticateApiKey } from '@/lib/api-auth' +import { withApiAuth } from '@/lib/api-auth' export async function GET( req: NextRequest, { params }: { params: { id: string } } ) { - if (!(await authenticateApiKey(req))) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + return withApiAuth(req, async () => { + const campaignId = params.id - const campaignId = params.id + const [sendStatsResult, eventStatsResult, topLinksResult, timelineResult] = + await Promise.all([ + db + .select({ + sent: sql`cast(count(case when status = 'sent' then 1 end) as int)`, + bounced: sql`cast(count(case when status = 'bounced' then 1 end) as int)`, + failed: sql`cast(count(case when status = 'failed' then 1 end) as int)`, + }) + .from(campaignSends) + .where(eq(campaignSends.campaignId, campaignId)), - const [sendStatsResult, eventStatsResult, topLinksResult, timelineResult] = - await Promise.all([ - db - .select({ - sent: sql`cast(count(case when status = 'sent' then 1 end) as int)`, - bounced: sql`cast(count(case when status = 'bounced' then 1 end) as int)`, - failed: sql`cast(count(case when status = 'failed' then 1 end) as int)`, - }) - .from(campaignSends) - .where(eq(campaignSends.campaignId, campaignId)), + db + .select({ + opens: sql`cast(count(distinct case when type = 'open' then campaign_send_id end) as int)`, + clicks: sql`cast(count(distinct case when type = 'click' then campaign_send_id end) as int)`, + }) + .from(campaignEvents) + .where(eq(campaignEvents.campaignId, campaignId)), - db - .select({ - opens: sql`cast(count(distinct case when type = 'open' then campaign_send_id end) as int)`, - clicks: sql`cast(count(distinct case when type = 'click' then campaign_send_id end) as int)`, - }) - .from(campaignEvents) - .where(eq(campaignEvents.campaignId, campaignId)), - - db - .select({ - url: campaignEvents.linkUrl, - count: sql`cast(count(*) as int)`, - }) - .from(campaignEvents) - .where( - and( - eq(campaignEvents.campaignId, campaignId), - eq(campaignEvents.type, 'click') + db + .select({ + url: campaignEvents.linkUrl, + count: sql`cast(count(*) as int)`, + }) + .from(campaignEvents) + .where( + and( + eq(campaignEvents.campaignId, campaignId), + eq(campaignEvents.type, 'click') + ) ) - ) - .groupBy(campaignEvents.linkUrl) - .orderBy(sql`count(*) desc`) - .limit(10), + .groupBy(campaignEvents.linkUrl) + .orderBy(sql`count(*) desc`) + .limit(10), - db - .select({ - hour: sql`date_trunc('hour', ${campaignEvents.createdAt})::text`, - opens: sql`cast(count(case when type = 'open' then 1 end) as int)`, - clicks: sql`cast(count(case when type = 'click' then 1 end) as int)`, - }) - .from(campaignEvents) - .where( - and( - eq(campaignEvents.campaignId, campaignId), - sql`${campaignEvents.createdAt} > now() - interval '7 days'` + db + .select({ + hour: sql`date_trunc('hour', ${campaignEvents.createdAt})::text`, + opens: sql`cast(count(case when type = 'open' then 1 end) as int)`, + clicks: sql`cast(count(case when type = 'click' then 1 end) as int)`, + }) + .from(campaignEvents) + .where( + and( + eq(campaignEvents.campaignId, campaignId), + sql`${campaignEvents.createdAt} > now() - interval '7 days'` + ) ) - ) - .groupBy(sql`date_trunc('hour', ${campaignEvents.createdAt})`) - .orderBy(sql`date_trunc('hour', ${campaignEvents.createdAt})`), - ]) + .groupBy(sql`date_trunc('hour', ${campaignEvents.createdAt})`) + .orderBy(sql`date_trunc('hour', ${campaignEvents.createdAt})`), + ]) - const sendStats = sendStatsResult[0] ?? { sent: 0, bounced: 0, failed: 0 } - const eventStats = eventStatsResult[0] ?? { opens: 0, clicks: 0 } + const sendStats = sendStatsResult[0] ?? { sent: 0, bounced: 0, failed: 0 } + const eventStats = eventStatsResult[0] ?? { opens: 0, clicks: 0 } - return NextResponse.json({ - data: { - sent: sendStats.sent, - bounced: sendStats.bounced, - failed: sendStats.failed, - opens: eventStats.opens, - clicks: eventStats.clicks, - topLinks: topLinksResult.map((r) => ({ url: r.url ?? '', count: r.count })), - timeline: timelineResult.map((r) => ({ hour: r.hour, opens: r.opens, clicks: r.clicks })), - }, - meta: {}, - error: null, + return NextResponse.json({ + data: { + sent: sendStats.sent, + bounced: sendStats.bounced, + failed: sendStats.failed, + opens: eventStats.opens, + clicks: eventStats.clicks, + topLinks: topLinksResult.map((r) => ({ url: r.url ?? '', count: r.count })), + timeline: timelineResult.map((r) => ({ hour: r.hour, opens: r.opens, clicks: r.clicks })), + }, + meta: {}, + error: null, + }) }) } diff --git a/app/api/v1/campaigns/route.ts b/app/api/v1/campaigns/route.ts index dfe59f9..7192e16 100644 --- a/app/api/v1/campaigns/route.ts +++ b/app/api/v1/campaigns/route.ts @@ -2,35 +2,33 @@ import { NextRequest, NextResponse } from 'next/server' import { db } from '@/lib/db' import { campaigns, lists } from '@/lib/db/schema' import { eq, desc, sql } from 'drizzle-orm' -import { authenticateApiKey } from '@/lib/api-auth' +import { withApiAuth } from '@/lib/api-auth' export async function GET(req: NextRequest) { - if (!(await authenticateApiKey(req))) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + return withApiAuth(req, async () => { + const rows = await db + .select({ + id: campaigns.id, + name: campaigns.name, + subject: campaigns.subject, + fromName: campaigns.fromName, + fromEmail: campaigns.fromEmail, + listId: campaigns.listId, + status: campaigns.status, + scheduledAt: campaigns.scheduledAt, + sentAt: campaigns.sentAt, + totalRecipients: campaigns.totalRecipients, + createdAt: campaigns.createdAt, + updatedAt: campaigns.updatedAt, + listName: lists.name, + sent: sql`(SELECT CAST(COUNT(*) AS INT) FROM campaign_sends WHERE campaign_id = ${campaigns.id} AND status = 'sent')`, + opens: sql`(SELECT CAST(COUNT(DISTINCT campaign_send_id) AS INT) FROM campaign_events WHERE campaign_id = ${campaigns.id} AND type = 'open')`, + clicks: sql`(SELECT CAST(COUNT(DISTINCT campaign_send_id) AS INT) FROM campaign_events WHERE campaign_id = ${campaigns.id} AND type = 'click')`, + }) + .from(campaigns) + .leftJoin(lists, eq(campaigns.listId, lists.id)) + .orderBy(desc(campaigns.createdAt)) - const rows = await db - .select({ - id: campaigns.id, - name: campaigns.name, - subject: campaigns.subject, - fromName: campaigns.fromName, - fromEmail: campaigns.fromEmail, - listId: campaigns.listId, - status: campaigns.status, - scheduledAt: campaigns.scheduledAt, - sentAt: campaigns.sentAt, - totalRecipients: campaigns.totalRecipients, - createdAt: campaigns.createdAt, - updatedAt: campaigns.updatedAt, - listName: lists.name, - sent: sql`(SELECT CAST(COUNT(*) AS INT) FROM campaign_sends WHERE campaign_id = ${campaigns.id} AND status = 'sent')`, - opens: sql`(SELECT CAST(COUNT(DISTINCT campaign_send_id) AS INT) FROM campaign_events WHERE campaign_id = ${campaigns.id} AND type = 'open')`, - clicks: sql`(SELECT CAST(COUNT(DISTINCT campaign_send_id) AS INT) FROM campaign_events WHERE campaign_id = ${campaigns.id} AND type = 'click')`, - }) - .from(campaigns) - .leftJoin(lists, eq(campaigns.listId, lists.id)) - .orderBy(desc(campaigns.createdAt)) - - return NextResponse.json({ data: rows, meta: {}, error: null }) + return NextResponse.json({ data: rows, meta: {}, error: null }) + }) } diff --git a/app/api/v1/contacts/[id]/gdpr-delete/route.ts b/app/api/v1/contacts/[id]/gdpr-delete/route.ts new file mode 100644 index 0000000..9500b70 --- /dev/null +++ b/app/api/v1/contacts/[id]/gdpr-delete/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from 'next/server' +import { db } from '@/lib/db' +import { contacts } from '@/lib/db/schema' +import { eq } from 'drizzle-orm' +import { hardDeleteContact } from '@/lib/gdpr' +import { withApiAuth } from '@/lib/api-auth' +import { auditFromApiKey, logAudit } from '@/lib/audit' + +export async function DELETE( + req: NextRequest, + { params }: { params: { id: string } } +) { + return withApiAuth(req, async (auth) => { + const confirm = req.nextUrl.searchParams.get('confirm') + if (!confirm) { + return NextResponse.json( + { error: 'Pass ?confirm= to confirm the hard delete.', data: null, meta: {} }, + { status: 400 }, + ) + } + + const [existing] = await db.select().from(contacts).where(eq(contacts.id, params.id)) + if (!existing) { + return NextResponse.json({ error: 'Contact not found', data: null, meta: {} }, { status: 404 }) + } + + if (existing.email.toLowerCase() !== confirm.toLowerCase()) { + return NextResponse.json( + { error: 'Confirmation email does not match the contact on file.', data: null, meta: {} }, + { status: 400 }, + ) + } + + const result = await hardDeleteContact(params.id) + if (!result) { + return NextResponse.json({ error: 'Contact not found', data: null, meta: {} }, { status: 404 }) + } + + await logAudit( + auditFromApiKey(req, auth), + 'contact.gdpr_delete', + { type: 'contact', id: params.id }, + { + email: result.email, + listId: result.listId, + sendCount: result.sendCount, + eventCount: result.eventCount, + }, + ) + + return NextResponse.json({ + data: { + deleted: true, + sendCount: result.sendCount, + eventCount: result.eventCount, + }, + meta: {}, + error: null, + }) + }) +} diff --git a/app/api/v1/contacts/[id]/gdpr-export/route.ts b/app/api/v1/contacts/[id]/gdpr-export/route.ts new file mode 100644 index 0000000..580a770 --- /dev/null +++ b/app/api/v1/contacts/[id]/gdpr-export/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from 'next/server' +import { buildContactExport } from '@/lib/gdpr' +import { withApiAuth } from '@/lib/api-auth' +import { auditFromApiKey, logAudit } from '@/lib/audit' + +export async function GET( + req: NextRequest, + { params }: { params: { id: string } } +) { + return withApiAuth(req, async (auth) => { + const data = await buildContactExport(params.id) + if (!data) { + return NextResponse.json( + { error: 'Contact not found', data: null, meta: {} }, + { status: 404 }, + ) + } + + await logAudit( + auditFromApiKey(req, auth), + 'contact.gdpr_export', + { type: 'contact', id: params.id }, + { sendCount: data.sends.length, eventCount: data.events.length }, + ) + + return NextResponse.json({ data, meta: {}, error: null }) + }) +} diff --git a/app/api/v1/lists/[listId]/contacts/[id]/route.ts b/app/api/v1/lists/[listId]/contacts/[id]/route.ts index 6fcbb5f..9cc443c 100644 --- a/app/api/v1/lists/[listId]/contacts/[id]/route.ts +++ b/app/api/v1/lists/[listId]/contacts/[id]/route.ts @@ -2,68 +2,79 @@ import { NextRequest, NextResponse } from 'next/server' import { db } from '@/lib/db' import { contacts } from '@/lib/db/schema' import { eq, and } from 'drizzle-orm' -import { authenticateApiKey } from '@/lib/api-auth' +import { withApiAuth } from '@/lib/api-auth' import { updateContactSchema } from '@/lib/validations/contacts' +import { auditFromApiKey, logAudit } from '@/lib/audit' export async function PUT( req: NextRequest, { params }: { params: { listId: string; id: string } } ) { - if (!(await authenticateApiKey(req))) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + return withApiAuth(req, async (auth) => { + let body: unknown + try { + body = await req.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body', data: null, meta: {} }, { status: 400 }) + } - let body: unknown - try { - body = await req.json() - } catch { - return NextResponse.json({ error: 'Invalid JSON body', data: null, meta: {} }, { status: 400 }) - } + const parsed = updateContactSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', data: null, meta: { details: parsed.error.flatten() } }, + { status: 400 } + ) + } - const parsed = updateContactSchema.safeParse(body) - if (!parsed.success) { - return NextResponse.json( - { error: 'Validation failed', data: null, meta: { details: parsed.error.flatten() } }, - { status: 400 } - ) - } + const updateData: Record = { updatedAt: new Date() } + if (parsed.data.email !== undefined) updateData.email = parsed.data.email + if (parsed.data.firstName !== undefined) updateData.firstName = parsed.data.firstName + if (parsed.data.lastName !== undefined) updateData.lastName = parsed.data.lastName + if (parsed.data.metadata !== undefined) updateData.metadata = parsed.data.metadata + if (parsed.data.status !== undefined) updateData.status = parsed.data.status - const updateData: Record = { updatedAt: new Date() } - if (parsed.data.email !== undefined) updateData.email = parsed.data.email - if (parsed.data.firstName !== undefined) updateData.firstName = parsed.data.firstName - if (parsed.data.lastName !== undefined) updateData.lastName = parsed.data.lastName - if (parsed.data.metadata !== undefined) updateData.metadata = parsed.data.metadata - if (parsed.data.status !== undefined) updateData.status = parsed.data.status + const [updated] = await db + .update(contacts) + .set(updateData) + .where(and(eq(contacts.id, params.id), eq(contacts.listId, params.listId))) + .returning() - const [updated] = await db - .update(contacts) - .set(updateData) - .where(and(eq(contacts.id, params.id), eq(contacts.listId, params.listId))) - .returning() + if (!updated) { + return NextResponse.json({ error: 'Contact not found', data: null, meta: {} }, { status: 404 }) + } - if (!updated) { - return NextResponse.json({ error: 'Contact not found', data: null, meta: {} }, { status: 404 }) - } + await logAudit( + auditFromApiKey(req, auth), + 'contact.update', + { type: 'contact', id: updated.id }, + { listId: params.listId, fields: Object.keys(updateData).filter((k) => k !== 'updatedAt') }, + ) - return NextResponse.json({ data: updated, meta: {}, error: null }) + return NextResponse.json({ data: updated, meta: {}, error: null }) + }) } export async function DELETE( req: NextRequest, { params }: { params: { listId: string; id: string } } ) { - if (!(await authenticateApiKey(req))) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + return withApiAuth(req, async (auth) => { + const [deleted] = await db + .delete(contacts) + .where(and(eq(contacts.id, params.id), eq(contacts.listId, params.listId))) + .returning({ id: contacts.id, email: contacts.email }) - const [deleted] = await db - .delete(contacts) - .where(and(eq(contacts.id, params.id), eq(contacts.listId, params.listId))) - .returning({ id: contacts.id }) + if (!deleted) { + return NextResponse.json({ error: 'Contact not found', data: null, meta: {} }, { status: 404 }) + } - if (!deleted) { - return NextResponse.json({ error: 'Contact not found', data: null, meta: {} }, { status: 404 }) - } + await logAudit( + auditFromApiKey(req, auth), + 'contact.delete', + { type: 'contact', id: deleted.id }, + { listId: params.listId, email: deleted.email }, + ) - return NextResponse.json({ data: { id: deleted.id }, meta: {}, error: null }) + return NextResponse.json({ data: { id: deleted.id }, meta: {}, error: null }) + }) } diff --git a/app/api/v1/lists/[listId]/contacts/bulk/route.ts b/app/api/v1/lists/[listId]/contacts/bulk/route.ts index cc27d59..ff2141e 100644 --- a/app/api/v1/lists/[listId]/contacts/bulk/route.ts +++ b/app/api/v1/lists/[listId]/contacts/bulk/route.ts @@ -2,74 +2,80 @@ import { NextRequest, NextResponse } from 'next/server' import { db } from '@/lib/db' import { contacts } from '@/lib/db/schema' import { sql } from 'drizzle-orm' -import { authenticateApiKey } from '@/lib/api-auth' +import { withApiAuth } from '@/lib/api-auth' import { bulkContactsSchema } from '@/lib/validations/contacts' +import { auditFromApiKey, logAudit } from '@/lib/audit' export async function POST( req: NextRequest, { params }: { params: { listId: string } } ) { - if (!(await authenticateApiKey(req))) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - let body: unknown - try { - body = await req.json() - } catch { - return NextResponse.json({ error: 'Invalid JSON body', data: null, meta: {} }, { status: 400 }) - } + return withApiAuth(req, async (auth) => { + let body: unknown + try { + body = await req.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body', data: null, meta: {} }, { status: 400 }) + } - const parsed = bulkContactsSchema.safeParse(body) - if (!parsed.success) { - return NextResponse.json( - { error: 'Validation failed', data: null, meta: { details: parsed.error.flatten() } }, - { status: 400 } - ) - } + const parsed = bulkContactsSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', data: null, meta: { details: parsed.error.flatten() } }, + { status: 400 } + ) + } - let inserted = 0 - let updated = 0 - let skipped = 0 + let inserted = 0 + const updated = 0 + let skipped = 0 - const batchSize = 500 - const items = parsed.data.contacts + const batchSize = 500 + const items = parsed.data.contacts - for (let i = 0; i < items.length; i += batchSize) { - const batch = items.slice(i, i + batchSize) - const values = batch.map((c) => ({ - listId: params.listId, - email: c.email, - firstName: c.firstName, - lastName: c.lastName, - metadata: c.metadata ?? {}, - })) + for (let i = 0; i < items.length; i += batchSize) { + const batch = items.slice(i, i + batchSize) + const values = batch.map((c) => ({ + listId: params.listId, + email: c.email, + firstName: c.firstName, + lastName: c.lastName, + metadata: c.metadata ?? {}, + })) - try { - const result = await db - .insert(contacts) - .values(values) - .onConflictDoUpdate({ - target: [contacts.listId, contacts.email], - set: { - firstName: sql`excluded.first_name`, - lastName: sql`excluded.last_name`, - metadata: sql`excluded.metadata`, - updatedAt: new Date(), - }, - }) - .returning({ id: contacts.id }) + try { + const result = await db + .insert(contacts) + .values(values) + .onConflictDoUpdate({ + target: [contacts.listId, contacts.email], + set: { + firstName: sql`excluded.first_name`, + lastName: sql`excluded.last_name`, + metadata: sql`excluded.metadata`, + updatedAt: new Date(), + }, + }) + .returning({ id: contacts.id }) - // Approximate: all returned rows are either inserted or updated - inserted += result.length - } catch { - skipped += batch.length + // Approximate: all returned rows are either inserted or updated + inserted += result.length + } catch { + skipped += batch.length + } } - } - return NextResponse.json({ - data: { inserted, updated, skipped }, - meta: {}, - error: null, + await logAudit( + auditFromApiKey(req, auth), + 'contact.upsert_bulk', + { type: 'list', id: params.listId }, + { inserted, updated, skipped, total: items.length }, + ) + + return NextResponse.json({ + data: { inserted, updated, skipped }, + meta: {}, + error: null, + }) }) } diff --git a/app/api/v1/lists/[listId]/contacts/route.ts b/app/api/v1/lists/[listId]/contacts/route.ts index de7681c..ec3ed1d 100644 --- a/app/api/v1/lists/[listId]/contacts/route.ts +++ b/app/api/v1/lists/[listId]/contacts/route.ts @@ -2,58 +2,57 @@ import { NextRequest, NextResponse } from 'next/server' import { db } from '@/lib/db' import { contacts } from '@/lib/db/schema' import { eq, ilike, and, count, SQL } from 'drizzle-orm' -import { authenticateApiKey } from '@/lib/api-auth' +import { withApiAuth } from '@/lib/api-auth' import { createContactSchema } from '@/lib/validations/contacts' +import { auditFromApiKey, logAudit } from '@/lib/audit' export async function GET( req: NextRequest, { params }: { params: { listId: string } } ) { - if (!(await authenticateApiKey(req))) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + return withApiAuth(req, async () => { + const { searchParams } = req.nextUrl + const page = Math.max(1, parseInt(searchParams.get('page') ?? '1', 10)) + const rawLimit = parseInt(searchParams.get('limit') ?? '50', 10) + const limit = Math.min(Math.max(1, rawLimit), 200) + const offset = (page - 1) * limit - const { searchParams } = req.nextUrl - const page = Math.max(1, parseInt(searchParams.get('page') ?? '1', 10)) - const rawLimit = parseInt(searchParams.get('limit') ?? '50', 10) - const limit = Math.min(Math.max(1, rawLimit), 200) - const offset = (page - 1) * limit + const status = searchParams.get('status') + const search = searchParams.get('search') - const status = searchParams.get('status') - const search = searchParams.get('search') + const conditions: SQL[] = [eq(contacts.listId, params.listId)] + if (status) conditions.push(eq(contacts.status, status)) + if (search) conditions.push(ilike(contacts.email, `%${search}%`)) - const conditions: SQL[] = [eq(contacts.listId, params.listId)] - if (status) conditions.push(eq(contacts.status, status)) - if (search) conditions.push(ilike(contacts.email, `%${search}%`)) + const where = and(...conditions) - const where = and(...conditions) + const [{ total }] = await db + .select({ total: count() }) + .from(contacts) + .where(where) - const [{ total }] = await db - .select({ total: count() }) - .from(contacts) - .where(where) + const data = await db + .select({ + id: contacts.id, + email: contacts.email, + firstName: contacts.firstName, + lastName: contacts.lastName, + metadata: contacts.metadata, + status: contacts.status, + createdAt: contacts.createdAt, + updatedAt: contacts.updatedAt, + }) + .from(contacts) + .where(where) + .limit(limit) + .offset(offset) + .orderBy(contacts.createdAt) - const data = await db - .select({ - id: contacts.id, - email: contacts.email, - firstName: contacts.firstName, - lastName: contacts.lastName, - metadata: contacts.metadata, - status: contacts.status, - createdAt: contacts.createdAt, - updatedAt: contacts.updatedAt, + return NextResponse.json({ + data, + meta: { page, limit, total }, + error: null, }) - .from(contacts) - .where(where) - .limit(limit) - .offset(offset) - .orderBy(contacts.createdAt) - - return NextResponse.json({ - data, - meta: { page, limit, total }, - error: null, }) } @@ -61,35 +60,40 @@ export async function POST( req: NextRequest, { params }: { params: { listId: string } } ) { - if (!(await authenticateApiKey(req))) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + return withApiAuth(req, async (auth) => { + let body: unknown + try { + body = await req.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body', data: null, meta: {} }, { status: 400 }) + } - let body: unknown - try { - body = await req.json() - } catch { - return NextResponse.json({ error: 'Invalid JSON body', data: null, meta: {} }, { status: 400 }) - } + const parsed = createContactSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', data: null, meta: { details: parsed.error.flatten() } }, + { status: 400 } + ) + } - const parsed = createContactSchema.safeParse(body) - if (!parsed.success) { - return NextResponse.json( - { error: 'Validation failed', data: null, meta: { details: parsed.error.flatten() } }, - { status: 400 } - ) - } + const [created] = await db + .insert(contacts) + .values({ + listId: params.listId, + email: parsed.data.email, + firstName: parsed.data.firstName, + lastName: parsed.data.lastName, + metadata: parsed.data.metadata ?? {}, + }) + .returning() - const [created] = await db - .insert(contacts) - .values({ - listId: params.listId, - email: parsed.data.email, - firstName: parsed.data.firstName, - lastName: parsed.data.lastName, - metadata: parsed.data.metadata ?? {}, - }) - .returning() + await logAudit( + auditFromApiKey(req, auth), + 'contact.create', + { type: 'contact', id: created.id }, + { listId: params.listId, email: created.email }, + ) - return NextResponse.json({ data: created, meta: {}, error: null }, { status: 201 }) + return NextResponse.json({ data: created, meta: {}, error: null }, { status: 201 }) + }) } diff --git a/app/api/v1/lists/route.ts b/app/api/v1/lists/route.ts index e061a90..ffe313c 100644 --- a/app/api/v1/lists/route.ts +++ b/app/api/v1/lists/route.ts @@ -2,61 +2,65 @@ import { NextRequest, NextResponse } from 'next/server' import { db } from '@/lib/db' import { lists, contacts } from '@/lib/db/schema' import { eq, sql } from 'drizzle-orm' -import { authenticateApiKey } from '@/lib/api-auth' +import { withApiAuth } from '@/lib/api-auth' import { createListSchema } from '@/lib/validations/lists' +import { auditFromApiKey, logAudit } from '@/lib/audit' export async function GET(req: NextRequest) { - if (!(await authenticateApiKey(req))) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + return withApiAuth(req, async () => { + const rows = await db + .select({ + id: lists.id, + name: lists.name, + description: lists.description, + createdAt: lists.createdAt, + updatedAt: lists.updatedAt, + total: sql`cast(count(${contacts.id}) as int)`, + active: sql`cast(count(case when ${contacts.status} = 'active' then 1 end) as int)`, + bounced: sql`cast(count(case when ${contacts.status} = 'bounced' then 1 end) as int)`, + unsubscribed: sql`cast(count(case when ${contacts.status} = 'unsubscribed' then 1 end) as int)`, + }) + .from(lists) + .leftJoin(contacts, eq(contacts.listId, lists.id)) + .groupBy(lists.id) + .orderBy(lists.createdAt) - const rows = await db - .select({ - id: lists.id, - name: lists.name, - description: lists.description, - createdAt: lists.createdAt, - updatedAt: lists.updatedAt, - total: sql`cast(count(${contacts.id}) as int)`, - active: sql`cast(count(case when ${contacts.status} = 'active' then 1 end) as int)`, - bounced: sql`cast(count(case when ${contacts.status} = 'bounced' then 1 end) as int)`, - unsubscribed: sql`cast(count(case when ${contacts.status} = 'unsubscribed' then 1 end) as int)`, - }) - .from(lists) - .leftJoin(contacts, eq(contacts.listId, lists.id)) - .groupBy(lists.id) - .orderBy(lists.createdAt) - - return NextResponse.json({ data: rows, meta: {}, error: null }) + return NextResponse.json({ data: rows, meta: {}, error: null }) + }) } export async function POST(req: NextRequest) { - if (!(await authenticateApiKey(req))) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + return withApiAuth(req, async (auth) => { + let body: unknown + try { + body = await req.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body', data: null, meta: {} }, { status: 400 }) + } - let body: unknown - try { - body = await req.json() - } catch { - return NextResponse.json({ error: 'Invalid JSON body', data: null, meta: {} }, { status: 400 }) - } + const parsed = createListSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', data: null, meta: { details: parsed.error.flatten() } }, + { status: 400 } + ) + } - const parsed = createListSchema.safeParse(body) - if (!parsed.success) { - return NextResponse.json( - { error: 'Validation failed', data: null, meta: { details: parsed.error.flatten() } }, - { status: 400 } - ) - } + const [created] = await db + .insert(lists) + .values({ + name: parsed.data.name, + description: parsed.data.description, + }) + .returning() - const [created] = await db - .insert(lists) - .values({ - name: parsed.data.name, - description: parsed.data.description, - }) - .returning() + await logAudit( + auditFromApiKey(req, auth), + 'list.create', + { type: 'list', id: created.id }, + { name: created.name }, + ) - return NextResponse.json({ data: created, meta: {}, error: null }, { status: 201 }) + return NextResponse.json({ data: created, meta: {}, error: null }, { status: 201 }) + }) } diff --git a/components/dashboard/sidebar.tsx b/components/dashboard/sidebar.tsx index 065d1a7..4569786 100644 --- a/components/dashboard/sidebar.tsx +++ b/components/dashboard/sidebar.tsx @@ -70,6 +70,18 @@ const settingsNavItems: NavItem[] = [ ), }, + { + label: 'Audit Log', + href: '/settings/audit-log', + icon: ( + + + + + + + ), + }, ] function NavLink({ item, pathname }: { item: NavItem; pathname: string }) { diff --git a/drizzle/migrations/0001_public_nighthawk.sql b/drizzle/migrations/0001_public_nighthawk.sql new file mode 100644 index 0000000..2bffcb9 --- /dev/null +++ b/drizzle/migrations/0001_public_nighthawk.sql @@ -0,0 +1,19 @@ +CREATE TABLE "audit_logs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "actor_type" text NOT NULL, + "actor_id" text, + "actor_label" text, + "action" text NOT NULL, + "resource_type" text, + "resource_id" text, + "metadata" jsonb DEFAULT '{}'::jsonb, + "ip_address" text, + "user_agent" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "api_keys" ADD COLUMN "rate_limit_per_minute" integer DEFAULT 60 NOT NULL;--> statement-breakpoint +ALTER TABLE "api_keys" ADD COLUMN "rate_limit_tokens" double precision DEFAULT 60 NOT NULL;--> statement-breakpoint +ALTER TABLE "api_keys" ADD COLUMN "rate_limit_updated_at" timestamp with time zone DEFAULT now() NOT NULL;--> statement-breakpoint +CREATE INDEX "audit_logs_created_at_idx" ON "audit_logs" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX "audit_logs_resource_idx" ON "audit_logs" USING btree ("resource_type","resource_id"); \ No newline at end of file diff --git a/drizzle/migrations/meta/0001_snapshot.json b/drizzle/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..471ea20 --- /dev/null +++ b/drizzle/migrations/meta/0001_snapshot.json @@ -0,0 +1,761 @@ +{ + "id": "92656192-2bd3-439a-bf76-4c5771e55c2d", + "prevId": "c66073d7-a246-4a46-8ee5-225f1fc78ebe", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rate_limit_per_minute": { + "name": "rate_limit_per_minute", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60 + }, + "rate_limit_tokens": { + "name": "rate_limit_tokens", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 60 + }, + "rate_limit_updated_at": { + "name": "rate_limit_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "key_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_label": { + "name": "actor_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_created_at_idx": { + "name": "audit_logs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_resource_idx": { + "name": "audit_logs_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.campaign_events": { + "name": "campaign_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "campaign_send_id": { + "name": "campaign_send_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "campaign_id": { + "name": "campaign_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "link_url": { + "name": "link_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "campaign_events_campaign_send_id_campaign_sends_id_fk": { + "name": "campaign_events_campaign_send_id_campaign_sends_id_fk", + "tableFrom": "campaign_events", + "tableTo": "campaign_sends", + "columnsFrom": [ + "campaign_send_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "campaign_events_campaign_id_campaigns_id_fk": { + "name": "campaign_events_campaign_id_campaigns_id_fk", + "tableFrom": "campaign_events", + "tableTo": "campaigns", + "columnsFrom": [ + "campaign_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.campaign_sends": { + "name": "campaign_sends", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "campaign_id": { + "name": "campaign_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "provider_message_id": { + "name": "provider_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "campaign_sends_campaign_id_campaigns_id_fk": { + "name": "campaign_sends_campaign_id_campaigns_id_fk", + "tableFrom": "campaign_sends", + "tableTo": "campaigns", + "columnsFrom": [ + "campaign_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "campaign_sends_contact_id_contacts_id_fk": { + "name": "campaign_sends_contact_id_contacts_id_fk", + "tableFrom": "campaign_sends", + "tableTo": "contacts", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "campaign_sends_campaign_id_contact_id_unique": { + "name": "campaign_sends_campaign_id_contact_id_unique", + "nullsNotDistinct": false, + "columns": [ + "campaign_id", + "contact_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.campaigns": { + "name": "campaigns", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "list_id": { + "name": "list_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "template_json": { + "name": "template_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "template_html": { + "name": "template_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "total_recipients": { + "name": "total_recipients", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cancel_requested": { + "name": "cancel_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "campaigns_list_id_lists_id_fk": { + "name": "campaigns_list_id_lists_id_fk", + "tableFrom": "campaigns", + "tableTo": "lists", + "columnsFrom": [ + "list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "campaigns_provider_id_email_providers_id_fk": { + "name": "campaigns_provider_id_email_providers_id_fk", + "tableFrom": "campaigns", + "tableTo": "email_providers", + "columnsFrom": [ + "provider_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contacts": { + "name": "contacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "list_id": { + "name": "list_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "unsubscribe_token": { + "name": "unsubscribe_token", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "contacts_list_id_lists_id_fk": { + "name": "contacts_list_id_lists_id_fk", + "tableFrom": "contacts", + "tableTo": "lists", + "columnsFrom": [ + "list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "contacts_unsubscribe_token_unique": { + "name": "contacts_unsubscribe_token_unique", + "nullsNotDistinct": false, + "columns": [ + "unsubscribe_token" + ] + }, + "contacts_list_id_email_unique": { + "name": "contacts_list_id_email_unique", + "nullsNotDistinct": false, + "columns": [ + "list_id", + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_providers": { + "name": "email_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config_encrypted": { + "name": "config_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "rate_limit_per_second": { + "name": "rate_limit_per_second", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lists": { + "name": "lists", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json index d2df13a..3dc0d7b 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1776275290720, "tag": "0000_lame_texas_twister", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1777753306543, + "tag": "0001_public_nighthawk", + "breakpoints": true } ] } \ No newline at end of file diff --git a/lib/api-auth.ts b/lib/api-auth.ts index f775885..ab8a1fa 100644 --- a/lib/api-auth.ts +++ b/lib/api-auth.ts @@ -1,12 +1,18 @@ -import { NextRequest } from 'next/server' +import { NextRequest, NextResponse } from 'next/server' import { db } from '@/lib/db' import { apiKeys } from '@/lib/db/schema' import { eq } from 'drizzle-orm' import bcrypt from 'bcryptjs' +import { consumeApiKeyToken, type RateLimitResult } from '@/lib/rate-limit' -export async function authenticateApiKey(req: NextRequest): Promise { +export interface ApiAuthContext { + keyId: string + keyName: string +} + +export async function authenticateApiKey(req: NextRequest): Promise { const auth = req.headers.get('authorization') - if (!auth?.startsWith('Bearer ')) return false + if (!auth?.startsWith('Bearer ')) return null const rawKey = auth.slice(7) const allKeys = await db.select().from(apiKeys) @@ -14,8 +20,53 @@ export async function authenticateApiKey(req: NextRequest): Promise { const valid = await bcrypt.compare(rawKey, key.keyHash) if (valid) { await db.update(apiKeys).set({ lastUsedAt: new Date() }).where(eq(apiKeys.id, key.id)) - return true + return { keyId: key.id, keyName: key.name } } } - return false + return null +} + +function rateLimitHeaders(rl: RateLimitResult): Record { + return { + 'X-RateLimit-Limit': String(rl.limit), + 'X-RateLimit-Remaining': String(rl.remaining), + 'X-RateLimit-Reset': String(rl.resetSeconds), + } +} + +function v1ErrorResponse(status: number, error: string, headers?: Record) { + return NextResponse.json( + { data: null, meta: {}, error }, + { status, headers }, + ) +} + +/** + * Wraps a v1 API route handler with API key auth + per-key rate limiting. + * On success, calls the handler with `auth` and decorates the response with X-RateLimit-* headers. + */ +export async function withApiAuth( + req: NextRequest, + handler: (auth: ApiAuthContext) => Promise, +): Promise { + const auth = await authenticateApiKey(req) + if (!auth) { + return v1ErrorResponse(401, 'Unauthorized') + } + + const rl = await consumeApiKeyToken(auth.keyId) + const headers = rateLimitHeaders(rl) + + if (!rl.allowed) { + return v1ErrorResponse(429, 'Rate limit exceeded', { + ...headers, + 'Retry-After': String(rl.resetSeconds), + }) + } + + const res = await handler(auth) + for (const [k, v] of Object.entries(headers)) { + res.headers.set(k, v) + } + return res } diff --git a/lib/audit/index.ts b/lib/audit/index.ts new file mode 100644 index 0000000..234f345 --- /dev/null +++ b/lib/audit/index.ts @@ -0,0 +1,90 @@ +import { NextRequest } from 'next/server' +import { getServerSession } from 'next-auth' +import { db } from '@/lib/db' +import { auditLogs } from '@/lib/db/schema' +import { authOptions } from '@/lib/auth' +import type { ApiAuthContext } from '@/lib/api-auth' + +export type ActorType = 'user' | 'api_key' | 'system' + +export interface AuditContext { + actorType: ActorType + actorId?: string | null + actorLabel?: string | null + ipAddress?: string | null + userAgent?: string | null +} + +export interface AuditResource { + type: string + id?: string | null +} + +function extractRequestMeta(req: NextRequest | null) { + if (!req) return { ipAddress: null, userAgent: null } + return { + ipAddress: req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? null, + userAgent: req.headers.get('user-agent') ?? null, + } +} + +export async function logAudit( + ctx: AuditContext, + action: string, + resource: AuditResource | null, + metadata: Record = {}, +): Promise { + try { + await db.insert(auditLogs).values({ + actorType: ctx.actorType, + actorId: ctx.actorId ?? null, + actorLabel: ctx.actorLabel ?? null, + action, + resourceType: resource?.type ?? null, + resourceId: resource?.id ?? null, + metadata, + ipAddress: ctx.ipAddress ?? null, + userAgent: ctx.userAgent ?? null, + }) + } catch (err) { + console.error('audit log insert failed', { action, err }) + } +} + +/** + * Build audit context from a session-protected internal route. Falls back to actorType: 'system' + * if there's no session (e.g. middleware was bypassed). Never throws. + */ +export async function auditFromSession(req: NextRequest): Promise { + const meta = extractRequestMeta(req) + try { + const session = await getServerSession(authOptions) + const email = session?.user?.email + if (email) { + return { + actorType: 'user', + actorId: email, + actorLabel: email, + ...meta, + } + } + } catch { + // ignore session resolution failure + } + return { + actorType: 'system', + actorId: null, + actorLabel: null, + ...meta, + } +} + +export function auditFromApiKey(req: NextRequest, auth: ApiAuthContext): AuditContext { + const meta = extractRequestMeta(req) + return { + actorType: 'api_key', + actorId: auth.keyId, + actorLabel: auth.keyName, + ...meta, + } +} diff --git a/lib/db/schema.ts b/lib/db/schema.ts index e867a05..742105b 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -1,5 +1,5 @@ import { - pgTable, uuid, text, timestamp, boolean, integer, jsonb, unique + pgTable, uuid, text, timestamp, boolean, integer, jsonb, unique, doublePrecision, index } from 'drizzle-orm/pg-core' export const lists = pgTable('lists', { @@ -83,9 +83,29 @@ export const apiKeys = pgTable('api_keys', { name: text('name').notNull(), keyHash: text('key_hash').notNull().unique(), lastUsedAt: timestamp('last_used_at', { withTimezone: true }), + rateLimitPerMinute: integer('rate_limit_per_minute').notNull().default(60), + rateLimitTokens: doublePrecision('rate_limit_tokens').notNull().default(60), + rateLimitUpdatedAt: timestamp('rate_limit_updated_at', { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }) +export const auditLogs = pgTable('audit_logs', { + id: uuid('id').primaryKey().defaultRandom(), + actorType: text('actor_type').notNull(), // user | api_key | system + actorId: text('actor_id'), + actorLabel: text('actor_label'), + action: text('action').notNull(), + resourceType: text('resource_type'), + resourceId: text('resource_id'), + metadata: jsonb('metadata').default({}).$type>(), + ipAddress: text('ip_address'), + userAgent: text('user_agent'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}, (t) => ({ + createdAtIdx: index('audit_logs_created_at_idx').on(t.createdAt), + resourceIdx: index('audit_logs_resource_idx').on(t.resourceType, t.resourceId), +})) + // Block type used in templateJson export type BlockType = 'heading' | 'text' | 'image' | 'button' | 'divider' | 'spacer' @@ -103,3 +123,4 @@ export type Campaign = typeof campaigns.$inferSelect export type CampaignSend = typeof campaignSends.$inferSelect export type CampaignEvent = typeof campaignEvents.$inferSelect export type ApiKey = typeof apiKeys.$inferSelect +export type AuditLog = typeof auditLogs.$inferSelect diff --git a/lib/gdpr/index.ts b/lib/gdpr/index.ts new file mode 100644 index 0000000..3caecae --- /dev/null +++ b/lib/gdpr/index.ts @@ -0,0 +1,78 @@ +import { db } from '@/lib/db' +import { contacts, campaigns, campaignSends, campaignEvents } from '@/lib/db/schema' +import { eq, inArray } from 'drizzle-orm' + +export interface ContactExport { + exportedAt: string + contact: typeof contacts.$inferSelect + sends: Array + events: Array +} + +export async function buildContactExport(contactId: string): Promise { + const [contact] = await db.select().from(contacts).where(eq(contacts.id, contactId)) + if (!contact) return null + + const sendsRows = await db + .select({ + send: campaignSends, + campaignName: campaigns.name, + campaignSubject: campaigns.subject, + }) + .from(campaignSends) + .leftJoin(campaigns, eq(campaignSends.campaignId, campaigns.id)) + .where(eq(campaignSends.contactId, contactId)) + + const sends = sendsRows.map((r) => ({ + ...r.send, + campaignName: r.campaignName, + campaignSubject: r.campaignSubject, + })) + + const sendIds = sends.map((s) => s.id) + const events = sendIds.length > 0 + ? await db.select().from(campaignEvents).where(inArray(campaignEvents.campaignSendId, sendIds)) + : [] + + return { + exportedAt: new Date().toISOString(), + contact, + sends, + events, + } +} + +export interface ContactDeleteResult { + email: string + listId: string + sendCount: number + eventCount: number +} + +export async function hardDeleteContact(contactId: string): Promise { + const [contact] = await db.select().from(contacts).where(eq(contacts.id, contactId)) + if (!contact) return null + + const sendsRows = await db + .select({ id: campaignSends.id }) + .from(campaignSends) + .where(eq(campaignSends.contactId, contactId)) + const sendIds = sendsRows.map((s) => s.id) + const eventCount = sendIds.length > 0 + ? (await db + .select({ id: campaignEvents.id }) + .from(campaignEvents) + .where(inArray(campaignEvents.campaignSendId, sendIds)) + ).length + : 0 + + // Cascade FK deletes campaign_sends and campaign_events automatically. + await db.delete(contacts).where(eq(contacts.id, contactId)) + + return { + email: contact.email, + listId: contact.listId, + sendCount: sendIds.length, + eventCount, + } +} diff --git a/lib/rate-limit/index.ts b/lib/rate-limit/index.ts new file mode 100644 index 0000000..bd166f7 --- /dev/null +++ b/lib/rate-limit/index.ts @@ -0,0 +1,75 @@ +import { db } from '@/lib/db' +import { sql } from 'drizzle-orm' + +export interface RateLimitResult { + allowed: boolean + limit: number + remaining: number + resetSeconds: number +} + +interface RateLimitRow { + rate_limit_per_minute: number + refilled: number + remaining_tokens: number + allowed: boolean +} + +/** + * Atomic token-bucket consume for an API key. The bucket refills at rate_limit_per_minute / 60 + * tokens per second up to a capacity of rate_limit_per_minute. Each call refills based on elapsed + * time and conditionally decrements one token in a single UPDATE so concurrent requests from the + * same key serialize through Postgres row-level locking. + */ +export async function consumeApiKeyToken(keyId: string): Promise { + const result = await db.execute(sql` + WITH refill AS ( + SELECT + id, + rate_limit_per_minute, + LEAST( + rate_limit_per_minute::double precision, + rate_limit_tokens + EXTRACT(EPOCH FROM (now() - rate_limit_updated_at)) * (rate_limit_per_minute / 60.0) + ) AS refilled + FROM api_keys + WHERE id = ${keyId} + FOR UPDATE + ), + upd AS ( + UPDATE api_keys + SET + rate_limit_tokens = CASE + WHEN refill.refilled >= 1 THEN refill.refilled - 1 + ELSE refill.refilled + END, + rate_limit_updated_at = now() + FROM refill + WHERE api_keys.id = refill.id + RETURNING + refill.rate_limit_per_minute, + refill.refilled, + api_keys.rate_limit_tokens AS remaining_tokens, + (refill.refilled >= 1) AS allowed + ) + SELECT * FROM upd + `) + + const rows = (result as unknown as { rows?: RateLimitRow[] }).rows + ?? (Array.isArray(result) ? (result as RateLimitRow[]) : []) + const row = rows[0] + + if (!row) { + return { allowed: false, limit: 0, remaining: 0, resetSeconds: 60 } + } + + const limit = Number(row.rate_limit_per_minute) + const remaining = Math.max(0, Math.floor(Number(row.remaining_tokens))) + const allowed = Boolean(row.allowed) + const refillRatePerSec = limit / 60 + const tokensRemaining = Number(row.remaining_tokens) + const resetSeconds = allowed + ? 0 + : Math.max(1, Math.ceil((1 - tokensRemaining) / refillRatePerSec)) + + return { allowed, limit, remaining, resetSeconds } +}