From f3a9fb66708eb26bcf33e0b9dfe8aeeb30737203 Mon Sep 17 00:00:00 2001
From: lorentz
Date: Thu, 23 Apr 2026 11:06:39 +0000
Subject: [PATCH] Clean restart: policyTypeFilter on templates, wipe+regenerate
Apr 1+ endpoint, admin UI button
---
ondeck/prisma/schema.prisma | 7 +-
ondeck/src/app/(dashboard)/admin/page.tsx | 8 +-
.../src/app/api/admin/clean-restart/route.ts | 299 ++++++++++++++++++
ondeck/src/app/api/admin/gap-fill/route.ts | 3 +
.../src/app/api/cron/auto-generate/route.ts | 40 ++-
.../components/admin/clean-restart-button.tsx | 107 +++++++
6 files changed, 443 insertions(+), 21 deletions(-)
create mode 100644 ondeck/src/app/api/admin/clean-restart/route.ts
create mode 100644 ondeck/src/components/admin/clean-restart-button.tsx
diff --git a/ondeck/prisma/schema.prisma b/ondeck/prisma/schema.prisma
index bae133a..2e7d52f 100644
--- a/ondeck/prisma/schema.prisma
+++ b/ondeck/prisma/schema.prisma
@@ -297,9 +297,10 @@ model TaskTemplate {
taskGroup String? @map("task_group")
isActive Boolean @default(true) @map("is_active")
displayOrder Int? @map("display_order")
- level TaskLevel @default(BOTH)
- designationId String? @map("designation_id")
- createdBy String? @map("created_by")
+ level TaskLevel @default(BOTH)
+ designationId String? @map("designation_id")
+ policyTypeFilter String? @map("policy_type_filter")
+ createdBy String? @map("created_by")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
diff --git a/ondeck/src/app/(dashboard)/admin/page.tsx b/ondeck/src/app/(dashboard)/admin/page.tsx
index 252f465..3fb2412 100644
--- a/ondeck/src/app/(dashboard)/admin/page.tsx
+++ b/ondeck/src/app/(dashboard)/admin/page.tsx
@@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import Link from 'next/link'
import { Shapes, FileText, Users, Database, Settings, ClipboardList, History, CalendarRange } from 'lucide-react'
+import { CleanRestartButton } from '@/components/admin/clean-restart-button'
export default async function AdminPage() {
const session = await getServerSession(authOptions)
@@ -94,7 +95,7 @@ export default async function AdminPage() {
-
+
{adminSections.map((section) => {
const Icon = section.icon
return (
@@ -123,6 +124,11 @@ export default async function AdminPage() {
)
})}
+
+
+
Danger Zone
+
+
)
}
diff --git a/ondeck/src/app/api/admin/clean-restart/route.ts b/ondeck/src/app/api/admin/clean-restart/route.ts
new file mode 100644
index 0000000..abfc437
--- /dev/null
+++ b/ondeck/src/app/api/admin/clean-restart/route.ts
@@ -0,0 +1,299 @@
+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-01')
+
+/**
+ * POST /api/admin/clean-restart
+ * Admin-only. Wipes all tasks/assignments and regenerates only for renewals >= April 1, 2026.
+ * Respects policyTypeFilter on TaskTemplate (e.g. mod-factor only for Workers Comp).
+ */
+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 log: string[] = []
+
+ // ─── 1. Wipe all tasks and assignments ──────────────────────────────────
+ const deletedAssignments = await prisma.taskAssignment.deleteMany({})
+ const deletedTasks = await prisma.task.deleteMany({})
+ log.push(`Deleted ${deletedAssignments.count} assignments, ${deletedTasks.count} tasks`)
+
+ let groupTasksCreated = 0
+ let policyTasksCreated = 0
+ let clientTasksCreated = 0
+ const errors: string[] = []
+
+ // ─── 2. Group-level tasks (renewalDate >= April 1) ──────────────────────
+ const groups = await prisma.policyGroup.findMany({
+ where: {
+ renewalDate: { gte: CUTOFF_DATE },
+ policies: { some: {} },
+ },
+ include: {
+ client: {
+ select: { designationId: true, designation2Id: true, claimsAdvocateId: true },
+ },
+ },
+ })
+
+ for (const group of groups) {
+ try {
+ const designationIds = [group.client.designationId, group.client.designation2Id].filter(Boolean) as string[]
+
+ const templates = await prisma.taskTemplate.findMany({
+ where: {
+ isActive: true,
+ level: { in: ['BOTH', 'RENEWAL_GROUP'] },
+ OR: [
+ { designationId: null },
+ ...(designationIds.length > 0 ? [{ designationId: { in: designationIds } }] : []),
+ ],
+ },
+ orderBy: [{ department: 'asc' }, { daysOffset: 'asc' }],
+ })
+
+ if (templates.length === 0) continue
+
+ const renewalDate = new Date(group.renewalDate)
+
+ const tasksToCreate = templates.map((template) => {
+ const dueDate = new Date(renewalDate)
+ dueDate.setDate(dueDate.getDate() + template.daysOffset)
+ return {
+ title: template.name,
+ description: template.description,
+ department: template.department,
+ timing: template.timing,
+ daysOffset: template.daysOffset,
+ dueDate,
+ status: 'NOT_STARTED' as const,
+ priority: template.defaultPriority,
+ clientId: group.clientId,
+ policyGroupId: group.id,
+ templateId: template.id,
+ }
+ })
+
+ const created = await prisma.task.createMany({ data: tasksToCreate, skipDuplicates: true })
+ groupTasksCreated += created.count
+
+ if (group.client.claimsAdvocateId && created.count > 0) {
+ const newTasks = await prisma.task.findMany({
+ where: { policyGroupId: group.id, templateId: { in: templates.map((t) => t.id) } },
+ select: { id: true },
+ })
+ await prisma.taskAssignment.createMany({
+ data: newTasks.map((t) => ({ taskId: t.id, userId: group.client.claimsAdvocateId! })),
+ skipDuplicates: true,
+ })
+ }
+ } catch (err: any) {
+ errors.push(`Group ${group.id}: ${err.message}`)
+ }
+ }
+
+ log.push(`Group tasks created: ${groupTasksCreated} (from ${groups.length} groups)`)
+
+ // ─── 3. Policy-level tasks (expirationDate + 1 >= April 1) ─────────────
+ const policies = await prisma.policy.findMany({
+ where: {
+ expirationDate: { gte: new Date(CUTOFF_DATE.getTime() - 86400 * 1000) }, // exp >= Mar 31
+ status: { notIn: ['Cancelled', 'Expired', 'Non-Renewed', 'Rewritten', 'Not taken'] },
+ },
+ select: {
+ id: true,
+ expirationDate: true,
+ clientId: true,
+ policyType: true,
+ client: {
+ select: { designationId: true, designation2Id: true, claimsAdvocateId: true },
+ },
+ },
+ })
+
+ for (const policy of policies) {
+ try {
+ const designationIds = [policy.client.designationId, policy.client.designation2Id].filter(Boolean) as string[]
+
+ const templates = await prisma.taskTemplate.findMany({
+ where: {
+ isActive: true,
+ level: { in: ['BOTH', 'POLICY'] },
+ OR: [
+ { designationId: null },
+ ...(designationIds.length > 0 ? [{ designationId: { in: designationIds } }] : []),
+ ],
+ },
+ orderBy: [{ department: 'asc' }, { daysOffset: 'asc' }],
+ })
+
+ if (templates.length === 0) continue
+
+ const anchorDate = new Date(policy.expirationDate)
+ anchorDate.setDate(anchorDate.getDate() + 1)
+
+ const tasksToCreate = templates
+ .filter((template) => {
+ if (template.policyTypeFilter && template.policyTypeFilter !== policy.policyType) return false
+ return true
+ })
+ .map((template) => {
+ const dueDate = new Date(anchorDate)
+ dueDate.setDate(dueDate.getDate() + template.daysOffset)
+ return {
+ title: template.name,
+ description: template.description,
+ department: template.department,
+ timing: template.timing,
+ daysOffset: template.daysOffset,
+ dueDate,
+ status: 'NOT_STARTED' as const,
+ priority: template.defaultPriority,
+ clientId: policy.clientId,
+ policyId: policy.id,
+ templateId: template.id,
+ }
+ })
+
+ if (tasksToCreate.length === 0) continue
+
+ const created = await prisma.task.createMany({ data: tasksToCreate, skipDuplicates: true })
+ policyTasksCreated += created.count
+
+ if (policy.client.claimsAdvocateId && created.count > 0) {
+ const newTasks = await prisma.task.findMany({
+ where: { policyId: policy.id, templateId: { in: templates.map((t) => t.id) } },
+ select: { id: true },
+ })
+ await prisma.taskAssignment.createMany({
+ data: newTasks.map((t) => ({ taskId: t.id, userId: policy.client.claimsAdvocateId! })),
+ skipDuplicates: true,
+ })
+ }
+ } catch (err: any) {
+ errors.push(`Policy ${policy.id}: ${err.message}`)
+ }
+ }
+
+ log.push(`Policy tasks created: ${policyTasksCreated} (from ${policies.length} policies)`)
+
+ // ─── 4. Client-level tasks ───────────────────────────────────────────────
+ const allClientIds = [...new Set([
+ ...groups.map((g) => g.clientId),
+ ...policies.map((p) => p.clientId),
+ ])]
+
+ for (const clientId of allClientIds) {
+ try {
+ const clientRecord = await prisma.client.findUnique({
+ where: { id: clientId },
+ select: {
+ designationId: true,
+ designation2Id: true,
+ claimsAdvocateId: true,
+ policyGroups: {
+ where: { renewalDate: { gte: CUTOFF_DATE } },
+ select: { renewalDate: true },
+ },
+ policies: {
+ where: {
+ expirationDate: { gte: new Date(CUTOFF_DATE.getTime() - 86400 * 1000) },
+ status: { notIn: ['Cancelled', 'Expired', 'Non-Renewed', 'Rewritten', 'Not taken'] },
+ },
+ select: { expirationDate: true },
+ },
+ },
+ })
+ if (!clientRecord) continue
+
+ const designationIds = [clientRecord.designationId, clientRecord.designation2Id].filter(Boolean) as string[]
+
+ const clientTemplates = await prisma.taskTemplate.findMany({
+ where: {
+ isActive: true,
+ level: 'CLIENT',
+ policyTypeFilter: null,
+ OR: [
+ { designationId: null },
+ ...(designationIds.length > 0 ? [{ designationId: { in: designationIds } }] : []),
+ ],
+ },
+ orderBy: [{ department: 'asc' }, { daysOffset: 'asc' }],
+ })
+
+ if (clientTemplates.length === 0) continue
+
+ const groupDates = clientRecord.policyGroups.map((g) => new Date(g.renewalDate).getTime())
+ const policyDates = clientRecord.policies.map((p) => {
+ const d = new Date(p.expirationDate)
+ d.setDate(d.getDate() + 1)
+ return d.getTime()
+ })
+ const allDates = [...groupDates, ...policyDates]
+ if (allDates.length === 0) continue
+ const anchorDate = new Date(Math.min(...allDates))
+
+ for (const template of clientTemplates) {
+ const dueDate = new Date(anchorDate)
+ dueDate.setDate(dueDate.getDate() + template.daysOffset)
+
+ const created = await prisma.task.create({
+ data: {
+ title: template.name,
+ description: template.description,
+ department: template.department,
+ timing: template.timing,
+ daysOffset: template.daysOffset,
+ dueDate,
+ status: 'NOT_STARTED',
+ priority: template.defaultPriority,
+ clientId,
+ templateId: template.id,
+ },
+ select: { id: true },
+ })
+ clientTasksCreated++
+
+ if (clientRecord.claimsAdvocateId) {
+ await prisma.taskAssignment.create({
+ data: { taskId: created.id, userId: clientRecord.claimsAdvocateId },
+ })
+ }
+ }
+ } catch (err: any) {
+ errors.push(`Client ${clientId}: ${err.message}`)
+ }
+ }
+
+ log.push(`Client tasks created: ${clientTasksCreated}`)
+
+ const totalCreated = groupTasksCreated + policyTasksCreated + clientTasksCreated
+ log.push(`Total tasks created: ${totalCreated}`)
+ if (errors.length > 0) log.push(`Errors: ${errors.length}`)
+
+ return NextResponse.json({
+ success: true,
+ summary: {
+ deletedTasks: deletedTasks.count,
+ groupsProcessed: groups.length,
+ policiesProcessed: policies.length,
+ clientsProcessed: allClientIds.length,
+ groupTasksCreated,
+ policyTasksCreated,
+ clientTasksCreated,
+ totalTasksCreated: totalCreated,
+ },
+ log,
+ errors: errors.length > 0 ? errors.slice(0, 20) : undefined,
+ })
+ } catch (error: any) {
+ console.error('Clean restart error:', error)
+ return NextResponse.json({ error: 'Internal server error', detail: error.message }, { status: 500 })
+ }
+}
diff --git a/ondeck/src/app/api/admin/gap-fill/route.ts b/ondeck/src/app/api/admin/gap-fill/route.ts
index 45a7c91..c3cbab9 100644
--- a/ondeck/src/app/api/admin/gap-fill/route.ts
+++ b/ondeck/src/app/api/admin/gap-fill/route.ts
@@ -107,6 +107,7 @@ export async function POST(request: NextRequest) {
id: true,
expirationDate: true,
clientId: true,
+ policyType: true,
client: { select: { designationId: true, designation2Id: true, claimsAdvocateId: true } },
},
})
@@ -133,6 +134,8 @@ export async function POST(request: NextRequest) {
anchorDate.setDate(anchorDate.getDate() + 1)
for (const template of templates) {
+ if (template.policyTypeFilter && template.policyTypeFilter !== policy.policyType) continue
+
const dueDate = new Date(anchorDate)
dueDate.setDate(dueDate.getDate() + template.daysOffset)
diff --git a/ondeck/src/app/api/cron/auto-generate/route.ts b/ondeck/src/app/api/cron/auto-generate/route.ts
index 58daa64..7d8a7d5 100644
--- a/ondeck/src/app/api/cron/auto-generate/route.ts
+++ b/ondeck/src/app/api/cron/auto-generate/route.ts
@@ -174,24 +174,30 @@ export async function POST(request: NextRequest) {
const anchorDate = new Date(policy.expirationDate)
anchorDate.setDate(anchorDate.getDate() + 1) // renewal date = expiration + 1
- const tasksToCreate = templates.map((template) => {
- const dueDate = new Date(anchorDate)
- dueDate.setDate(dueDate.getDate() + template.daysOffset)
- return {
- title: template.name,
- description: template.description,
- department: template.department,
- timing: template.timing,
- daysOffset: template.daysOffset,
- dueDate,
- status: 'NOT_STARTED' as const,
- priority: template.defaultPriority,
- clientId: policy.clientId,
- policyId: policy.id,
- templateId: template.id,
- }
- })
+ const tasksToCreate = templates
+ .filter((template) => {
+ if (template.policyTypeFilter && template.policyTypeFilter !== policy.policyType) return false
+ return true
+ })
+ .map((template) => {
+ const dueDate = new Date(anchorDate)
+ dueDate.setDate(dueDate.getDate() + template.daysOffset)
+ return {
+ title: template.name,
+ description: template.description,
+ department: template.department,
+ timing: template.timing,
+ daysOffset: template.daysOffset,
+ dueDate,
+ status: 'NOT_STARTED' as const,
+ priority: template.defaultPriority,
+ clientId: policy.clientId,
+ policyId: policy.id,
+ templateId: template.id,
+ }
+ })
+ if (tasksToCreate.length === 0) continue
const created = await prisma.task.createMany({ data: tasksToCreate })
policyTasksCreated += created.count
diff --git a/ondeck/src/components/admin/clean-restart-button.tsx b/ondeck/src/components/admin/clean-restart-button.tsx
new file mode 100644
index 0000000..44e6ec5
--- /dev/null
+++ b/ondeck/src/components/admin/clean-restart-button.tsx
@@ -0,0 +1,107 @@
+'use client'
+
+import { useState } from 'react'
+import { Button } from '@/components/ui/button'
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from '@/components/ui/alert-dialog'
+import { Loader2, RotateCcw } from 'lucide-react'
+
+export function CleanRestartButton() {
+ const [loading, setLoading] = useState(false)
+ const [result, setResult] = useState<{ summary: Record; log: string[] } | null>(null)
+ const [error, setError] = useState('')
+
+ const handleRun = async () => {
+ setLoading(true)
+ setResult(null)
+ setError('')
+ try {
+ const res = await fetch('/api/admin/clean-restart', { method: 'POST' })
+ const data = await res.json()
+ if (!res.ok) {
+ setError(data.error || 'Unknown error')
+ } else {
+ setResult(data)
+ }
+ } catch (e: any) {
+ setError(e.message)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+
+
+
+
+ Clean Restart (April 1, 2026+)
+
+
+ Wipes all tasks and regenerates only for renewals on or after April 1, 2026.
+ Workers Comp mod-factor tasks will only be created for WC policies.
+
+
+
+
+
+
+ {loading ? : null}
+ {loading ? 'Running…' : 'Run Clean Restart'}
+
+
+
+
+ Wipe and regenerate all tasks?
+
+ This will permanently delete every task and assignment in the system,
+ then regenerate tasks for renewals on or after April 1, 2026.
+ Clients, policies, and policy groups will not be affected.
+ This cannot be undone.
+
+
+
+ Cancel
+
+ Yes, wipe and regenerate
+
+
+
+
+
+
+ {error && (
+
{error}
+ )}
+
+ {result && (
+
+
✓ Complete
+
+ Tasks deleted
+ {result.summary.deletedTasks}
+ Groups processed
+ {result.summary.groupsProcessed}
+ Policies processed
+ {result.summary.policiesProcessed}
+ Tasks created
+ {result.summary.totalTasksCreated}
+
+
+ )}
+
+ )
+}