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).
This commit is contained in:
lorentz 2026-05-20 17:27:27 +00:00
parent c20d6b8f1a
commit 79b6991b31
2 changed files with 125 additions and 1 deletions

View file

@ -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/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/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 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 *)"
] ]
} }
} }

View file

@ -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 })
}
}