diff --git a/src/app/(dashboard)/[portSlug]/admin/page.tsx b/src/app/(dashboard)/[portSlug]/admin/page.tsx index bb0b670..71b01f9 100644 --- a/src/app/(dashboard)/[portSlug]/admin/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/page.tsx @@ -197,7 +197,7 @@ const GROUPS: AdminGroup[] = [ { href: 'backup', label: 'Backup & Restore', - description: 'Database snapshots and on-demand exports.', + description: 'Backup posture + retention policy (read-only).', icon: HardDrive, }, { @@ -221,8 +221,8 @@ const GROUPS: AdminGroup[] = [ }, { href: 'onboarding', - label: 'Onboarding', - description: 'Initial-setup wizard for fresh ports.', + label: 'Onboarding checklist', + description: 'Setup checklist for fresh ports (read-only references).', icon: LayoutDashboard, }, ], diff --git a/src/app/api/v1/admin/audit/route.ts b/src/app/api/v1/admin/audit/route.ts index d27c7ba..24298a6 100644 --- a/src/app/api/v1/admin/audit/route.ts +++ b/src/app/api/v1/admin/audit/route.ts @@ -9,6 +9,7 @@ import { db } from '@/lib/db'; import { user } from '@/lib/db/schema/users'; import { errorResponse } from '@/lib/errors'; import { createAuditLog } from '@/lib/audit'; +import { redis } from '@/lib/redis'; const auditQuerySchema = z.object({ limit: z.coerce.number().int().min(1).max(200).default(50), @@ -69,31 +70,38 @@ export const GET = withAuth( })); // Watch-the-watchers: record that an operator opened the audit log - // page. Only fire on the first page (no cursor) so paginating - // through doesn't spam the log; use 'view' at warning severity so - // the entry stands out in the inspector. + // page. Per-user 60s TTL dedupe so heavy filter-tweaking doesn't + // bury the log in a flood of self-references; first request in + // each window writes the row, subsequent requests within the + // window are silent. if (!cursor) { - void createAuditLog({ - userId: ctx.userId, - portId: ctx.portId, - action: 'view', - entityType: 'audit_log', - entityId: 'list', - metadata: { - filters: { - entityType: query.entityType, - action: query.action, - severity: query.severity, - source: query.source, - userId: query.userId, - entityId: query.entityId, - search: query.search, + const dedupeKey = `audit-view:${ctx.userId}:${ctx.portId}`; + // SET NX returns 'OK' on insert, null when the key already exists + // (TTL still ticking down). + const inserted = await redis.set(dedupeKey, '1', 'EX', 60, 'NX'); + if (inserted === 'OK') { + void createAuditLog({ + userId: ctx.userId, + portId: ctx.portId, + action: 'view', + entityType: 'audit_log', + entityId: 'list', + metadata: { + filters: { + entityType: query.entityType, + action: query.action, + severity: query.severity, + source: query.source, + userId: query.userId, + entityId: query.entityId, + search: query.search, + }, }, - }, - ipAddress: ctx.ipAddress, - userAgent: ctx.userAgent, - severity: 'warning', - }); + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + severity: 'warning', + }); + } } return NextResponse.json({ diff --git a/src/lib/queue/workers/documents.ts b/src/lib/queue/workers/documents.ts index 4a35abb..29b403a 100644 --- a/src/lib/queue/workers/documents.ts +++ b/src/lib/queue/workers/documents.ts @@ -46,8 +46,52 @@ export const documentsWorker = new Worker( }, ); -documentsWorker.on('failed', (job, err) => { +documentsWorker.on('failed', async (job, err) => { logger.error({ jobId: job?.id, jobName: job?.name, err }, 'Documents job failed'); + + // Final-attempt failure on documenso-void → notify all super admins + // so they can void the envelope manually in Documenso. Without this + // alert hook, a persistent 401/403 from Documenso retries until + // BullMQ exhausts attempts and the failure disappears into the + // audit log unnoticed. + if (job?.name === 'documenso-void' && job.attemptsMade >= (job.opts.attempts ?? 1)) { + try { + const { documentId, documensoId, portId } = (job.data ?? {}) as { + documentId?: string; + documensoId?: string; + portId?: string; + }; + if (!documentId || !documensoId) return; + const { db } = await import('@/lib/db'); + const { userProfiles } = await import('@/lib/db/schema/users'); + const { createNotification } = await import('@/lib/services/notifications.service'); + const { eq, and } = await import('drizzle-orm'); + + const superAdmins = await db + .select({ userId: userProfiles.userId }) + .from(userProfiles) + .where(and(eq(userProfiles.isSuperAdmin, true), eq(userProfiles.isActive, true))); + // createNotification requires a portId; if the job didn't carry + // one we can't tag the notification — bail out cleanly. + if (!portId) return; + for (const admin of superAdmins) { + void createNotification({ + portId, + userId: admin.userId, + type: 'system_alert', + title: 'Documenso void failed', + description: `Document ${documentId.slice(0, 8)}… could not be voided in Documenso after ${job.attemptsMade} attempts. Void manually in Documenso if still active.`, + link: `/admin/documents`, + entityType: 'document', + entityId: documentId, + dedupeKey: `doc:void_failed:${documentId}`, + cooldownMs: 0, + }); + } + } catch (notifyErr) { + logger.error({ notifyErr }, 'Failed to alert super-admins of documenso-void DLQ'); + } + } }); attachWorkerAudit(documentsWorker, 'documents');