From 79b6991b31a498313d25819895434733d91b30cb Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 20 May 2026 17:27:27 +0000 Subject: [PATCH] Add: /api/admin/bulk-close-pre-cutoff route with audit logging Marks all open tasks with dueDate < 2026-04-01 as COMPLETED. Supports ?dryRun=true for safe preview before execution. Writes batched AuditLog entries + a summary entry per run. Admin-only (403 for non-admins). --- .claude/settings.local.json | 5 +- .../api/admin/bulk-close-pre-cutoff/route.ts | 121 ++++++++++++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 ondeck/src/app/api/admin/bulk-close-pre-cutoff/route.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 51b461a..0a8e53f 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -34,7 +34,10 @@ "Bash(curl -s 'https://api.pangolin.wulfconsulting.cloud/v1/org/seubert-and-associates/domains?limit=1000' -H 'Authorization: Bearer uqfgsbiou7j9fvt.fcznfl7wtfi7ap3267asgqxgsjzwyzej5tdiun62')", "Bash(curl -s https://api.pangolin.wulfconsulting.cloud/v1/target/48 -H 'Authorization: Bearer uqfgsbiou7j9fvt.fcznfl7wtfi7ap3267asgqxgsjzwyzej5tdiun62')", "Bash(curl -s https://api.pangolin.wulfconsulting.cloud/v1/resource/42/rules -H 'Authorization: Bearer uqfgsbiou7j9fvt.fcznfl7wtfi7ap3267asgqxgsjzwyzej5tdiun62')", - "Bash(curl -s -X PUT https://api.pangolin.wulfconsulting.cloud/v1/resource/42/rule -H 'Authorization: Bearer uqfgsbiou7j9fvt.fcznfl7wtfi7ap3267asgqxgsjzwyzej5tdiun62' -H 'Content-Type: application/json' -d '{:*)" + "Bash(curl -s -X PUT https://api.pangolin.wulfconsulting.cloud/v1/resource/42/rule -H 'Authorization: Bearer uqfgsbiou7j9fvt.fcznfl7wtfi7ap3267asgqxgsjzwyzej5tdiun62' -H 'Content-Type: application/json' -d '{:*)", + "Bash(PGPASSWORD='ondeck_password_2026!' psql *)", + "Bash(docker exec *)", + "Bash(DATABASE_URL=\"postgresql://ondeck_user:ondeck_password_2026!@localhost:5432/ondeck\" node *)" ] } } diff --git a/ondeck/src/app/api/admin/bulk-close-pre-cutoff/route.ts b/ondeck/src/app/api/admin/bulk-close-pre-cutoff/route.ts new file mode 100644 index 0000000..0de22ec --- /dev/null +++ b/ondeck/src/app/api/admin/bulk-close-pre-cutoff/route.ts @@ -0,0 +1,121 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { prisma } from '@/lib/db' + +const CUTOFF_DATE = new Date('2026-04-01T00:00:00.000Z') + +/** + * POST /api/admin/bulk-close-pre-cutoff + * Admin-only. + * + * Marks all NOT_STARTED tasks with dueDate < 2026-04-01 as COMPLETED, + * with naReason noting they were bulk-closed as pre-cutoff historical data. + * Writes a single summarising AuditLog entry + one entry per batch chunk. + * + * Query param: ?dryRun=true — count only, no mutations. + */ +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const userRoles = (session.user as any).roles || [] + if (!userRoles.includes('Admin')) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + + const userId = (session.user as any).id as string + const dryRun = new URL(request.url).searchParams.get('dryRun') === 'true' + + // ── Count affected rows ──────────────────────────────────────────────── + const affected = await prisma.task.count({ + where: { + dueDate: { lt: CUTOFF_DATE }, + status: { notIn: ['COMPLETED', 'CANCELLED', 'NA'] }, + }, + }) + + if (dryRun) { + return NextResponse.json({ + dryRun: true, + wouldClose: affected, + cutoff: CUTOFF_DATE.toISOString().slice(0, 10), + }) + } + + if (affected === 0) { + return NextResponse.json({ closed: 0, message: 'No eligible tasks found.' }) + } + + // ── Bulk-update in batches of 500 to avoid lock contention ──────────── + const BATCH = 500 + let closed = 0 + const now = new Date() + const naReason = 'Bulk-closed: pre-cutoff historical data (tasks due before 2026-04-01)' + + while (closed < affected) { + // Fetch a batch of IDs + const ids = await prisma.task.findMany({ + where: { + dueDate: { lt: CUTOFF_DATE }, + status: { notIn: ['COMPLETED', 'CANCELLED', 'NA'] }, + }, + select: { id: true }, + take: BATCH, + }) + + if (ids.length === 0) break + + await prisma.task.updateMany({ + where: { id: { in: ids.map((t) => t.id) } }, + data: { + status: 'COMPLETED', + completedAt: now, + completedBy: userId, + naReason, + }, + }) + + closed += ids.length + + await prisma.auditLog.create({ + data: { + userId, + action: 'BULK_CLOSE_PRE_CUTOFF', + entityType: 'Task', + newValues: { + batchSize: ids.length, + runningTotal: closed, + cutoffDate: CUTOFF_DATE.toISOString().slice(0, 10), + reason: naReason, + taskIds: ids.map((t) => t.id), + }, + }, + }) + } + + // ── Summary audit entry ─────────────────────────────────────────────── + await prisma.auditLog.create({ + data: { + userId, + action: 'BULK_CLOSE_PRE_CUTOFF_SUMMARY', + entityType: 'Task', + newValues: { + totalClosed: closed, + cutoffDate: CUTOFF_DATE.toISOString().slice(0, 10), + executedAt: now.toISOString(), + executedBy: (session.user as any).email ?? userId, + reason: naReason, + }, + }, + }) + + return NextResponse.json({ + closed, + cutoff: CUTOFF_DATE.toISOString().slice(0, 10), + executedAt: now.toISOString(), + message: `${closed} tasks marked COMPLETED (pre-cutoff bulk close).`, + }) + } catch (error: any) { + console.error('bulk-close-pre-cutoff error:', error) + return NextResponse.json({ error: 'Internal server error', detail: error.message }, { status: 500 }) + } +}