Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ ENCRYPTION_KEY= # Generate with: openssl rand -hex 32
# Worker
WORKER_CONCURRENCY=5

# Audit log
AUDIT_LOG_RETENTION_DAYS=365 # Daily purge job deletes entries older than this. Set to 0 to disable.
AUDIT_LOG_EXPORT_MAX_ROWS=100000 # Cap for CSV exports; tighten the date range if exceeded.

# Webhooks (optional, for bounce/complaint tracking)
# RESEND_WEBHOOK_SECRET= # From Resend dashboard

Expand Down
71 changes: 60 additions & 11 deletions app/(dashboard)/settings/audit-log/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,17 +48,38 @@ export default function AuditLogPage() {
const [actorType, setActorType] = useState<string>("")
const [resourceType, setResourceType] = useState<string>("")
const [actionFilter, setActionFilter] = useState<string>("")
const [fromDate, setFromDate] = useState<string>("")
const [toDate, setToDate] = useState<string>("")
const [page, setPage] = useState(1)

function buildFilterParams(): URLSearchParams {
const params = new URLSearchParams()
if (actorType) params.set("actorType", actorType)
if (resourceType) params.set("resourceType", resourceType)
if (actionFilter) params.set("action", actionFilter)
if (fromDate) params.set("from", new Date(fromDate).toISOString())
if (toDate) {
const to = new Date(toDate)
to.setHours(23, 59, 59, 999)
params.set("to", to.toISOString())
}
return params
}

function handleExport() {
const params = buildFilterParams()
params.set("format", "csv")
window.location.href = `/api/internal/audit-logs?${params.toString()}`
}

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 params = buildFilterParams()
params.set("page", String(page))
params.set("limit", "50")
const res = await fetch(`/api/internal/audit-logs?${params.toString()}`)
if (!res.ok || cancelled) return
const json = await res.json()
Expand All @@ -72,17 +93,23 @@ export default function AuditLogPage() {
}
fetchLogs()
return () => { cancelled = true }
}, [page, actorType, resourceType, actionFilter])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, actorType, resourceType, actionFilter, fromDate, toDate])

const totalPages = Math.max(1, Math.ceil(meta.total / meta.limit))

return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold font-heading">Audit Log</h1>
<p className="text-sm text-muted-foreground mt-1">
Append-only record of every state-changing action.
</p>
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold font-heading">Audit Log</h1>
<p className="text-sm text-muted-foreground mt-1">
Append-only record of every state-changing action.
</p>
</div>
<Button variant="outline" size="sm" onClick={handleExport} disabled={loading}>
Export CSV
</Button>
</div>

<div className="flex flex-wrap items-end gap-3">
Expand Down Expand Up @@ -123,14 +150,36 @@ export default function AuditLogPage() {
className="w-56 h-9"
/>
</div>
{(actorType || resourceType || actionFilter) && (
<div className="space-y-1">
<Label htmlFor="filter-from" className="text-xs">From</Label>
<Input
id="filter-from"
type="date"
value={fromDate}
onChange={(e) => { setFromDate(e.target.value); setPage(1) }}
className="w-40 h-9"
/>
</div>
<div className="space-y-1">
<Label htmlFor="filter-to" className="text-xs">To</Label>
<Input
id="filter-to"
type="date"
value={toDate}
onChange={(e) => { setToDate(e.target.value); setPage(1) }}
className="w-40 h-9"
/>
</div>
{(actorType || resourceType || actionFilter || fromDate || toDate) && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setActorType("")
setResourceType("")
setActionFilter("")
setFromDate("")
setToDate("")
setPage(1)
}}
>
Expand Down
84 changes: 75 additions & 9 deletions app/api/internal/audit-logs/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,17 @@ 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 DEFAULT_EXPORT_MAX_ROWS = 100_000

function parseFilters(searchParams: URLSearchParams): SQL | undefined {
const conditions: SQL[] = []
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))
Expand All @@ -32,7 +27,78 @@ export async function GET(req: NextRequest) {
if (!Number.isNaN(to.getTime())) conditions.push(lte(auditLogs.createdAt, to))
}

const where = conditions.length > 0 ? and(...conditions) : undefined
return conditions.length > 0 ? and(...conditions) : undefined
}

const CSV_COLUMNS = [
'id',
'createdAt',
'actorType',
'actorId',
'actorLabel',
'action',
'resourceType',
'resourceId',
'ipAddress',
'userAgent',
'metadata',
] as const

function csvEscape(value: unknown): string {
if (value === null || value === undefined) return ''
const str = value instanceof Date ? value.toISOString() : typeof value === 'object' ? JSON.stringify(value) : String(value)
if (/[",\n\r]/.test(str)) {
return `"${str.replace(/"/g, '""')}"`
}
return str
}

export async function GET(req: NextRequest) {
const { searchParams } = req.nextUrl
const where = parseFilters(searchParams)
const format = searchParams.get('format')

if (format === 'csv') {
const exportMax = parseInt(process.env.AUDIT_LOG_EXPORT_MAX_ROWS ?? String(DEFAULT_EXPORT_MAX_ROWS), 10)
const cap = Number.isFinite(exportMax) && exportMax > 0 ? exportMax : DEFAULT_EXPORT_MAX_ROWS

const [{ total }] = await db.select({ total: count() }).from(auditLogs).where(where)
if (total > cap) {
return NextResponse.json(
{
error: `Export would return ${total} rows, exceeding the cap of ${cap}. Narrow the date range or filters.`,
},
{ status: 400 },
)
}

const rows = await db
.select()
.from(auditLogs)
.where(where)
.orderBy(desc(auditLogs.createdAt))
.limit(cap)

const lines: string[] = [CSV_COLUMNS.join(',')]
for (const row of rows) {
lines.push(CSV_COLUMNS.map((col) => csvEscape((row as Record<string, unknown>)[col])).join(','))
}
const body = lines.join('\n') + '\n'
const filename = `audit-logs-${new Date().toISOString().slice(0, 19).replace(/:/g, '')}.csv`

return new NextResponse(body, {
status: 200,
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="${filename}"`,
},
})
}

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 [{ total }] = await db
.select({ total: count() })
Expand Down
13 changes: 11 additions & 2 deletions app/api/internal/suppressions/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import { NextResponse } from 'next/server'
import { NextRequest, NextResponse } from 'next/server'
import { unsuppressEmailById } from '@/lib/suppressions'
import { auditFromSession, logAudit } from '@/lib/audit'

export async function DELETE(
_req: Request,
req: NextRequest,
{ params }: { params: { id: string } }
) {
const removed = await unsuppressEmailById(params.id)
if (!removed) {
return NextResponse.json({ error: 'Suppression not found' }, { status: 404 })
}

await logAudit(
await auditFromSession(req),
'suppression.delete',
{ type: 'suppression', id: params.id },
{ email: removed.email },
)

return NextResponse.json({ success: true })
}
9 changes: 9 additions & 0 deletions app/api/internal/suppressions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { suppressions } from '@/lib/db/schema'
import { ilike, sql, desc } from 'drizzle-orm'
import { createSuppressionSchema } from '@/lib/validations/suppressions'
import { suppressEmail, normalizeEmail } from '@/lib/suppressions'
import { auditFromSession, logAudit } from '@/lib/audit'

export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url)
Expand Down Expand Up @@ -53,5 +54,13 @@ export async function POST(req: NextRequest) {
})

const [row] = await db.select().from(suppressions).where(sql`${suppressions.email} = ${email}`).limit(1)

await logAudit(
await auditFromSession(req),
'suppression.create',
{ type: 'suppression', id: row?.id ?? null },
{ email, reason: parsed.data.reason ?? 'manual', source: parsed.data.source ?? 'manual' },
)

return NextResponse.json(row, { status: 201 })
}
14 changes: 14 additions & 0 deletions app/api/internal/suppressions/upload/confirm/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3'
import * as XLSX from 'xlsx'
import { uploadConfirmSchema, SUPPRESSION_REASONS } from '@/lib/validations/suppressions'
import { suppressEmailsBulk, type SuppressInput, type SuppressionReason } from '@/lib/suppressions'
import { auditFromSession, logAudit } from '@/lib/audit'

const s3 = new S3Client({
region: process.env.S3_REGION!,
Expand Down Expand Up @@ -70,5 +71,18 @@ export async function POST(request: NextRequest) {
}

const result = await suppressEmailsBulk(inputs)

await logAudit(
await auditFromSession(request),
'suppression.bulk_import',
{ type: 'suppression', id: null },
{
filename: filename ?? null,
submitted: inputs.length,
inserted: result.inserted,
skipped: result.skipped,
},
)

return NextResponse.json(result)
}
25 changes: 16 additions & 9 deletions app/api/v1/suppressions/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
import { NextRequest, NextResponse } from 'next/server'
import { authenticateApiKey } from '@/lib/api-auth'
import { withApiAuth } from '@/lib/api-auth'
import { unsuppressEmailById } from '@/lib/suppressions'
import { auditFromApiKey, logAudit } from '@/lib/audit'

export async function DELETE(
req: NextRequest,
{ params }: { params: { id: string } }
) {
if (!(await authenticateApiKey(req))) {
return NextResponse.json({ error: 'Unauthorized', data: null, meta: {} }, { status: 401 })
}
return withApiAuth(req, async (auth) => {
const removed = await unsuppressEmailById(params.id)
if (!removed) {
return NextResponse.json({ error: 'Suppression not found', data: null, meta: {} }, { status: 404 })
}

const removed = await unsuppressEmailById(params.id)
if (!removed) {
return NextResponse.json({ error: 'Suppression not found', data: null, meta: {} }, { status: 404 })
}
return NextResponse.json({ data: { id: params.id, removed: true }, meta: {}, error: null })
await logAudit(
auditFromApiKey(req, auth),
'suppression.delete',
{ type: 'suppression', id: params.id },
{ email: removed.email },
)

return NextResponse.json({ data: { id: params.id, removed: true }, meta: {}, error: null })
})
}
Loading
Loading