Clean restart: policyTypeFilter on templates, wipe+regenerate Apr 1+ endpoint, admin UI button

This commit is contained in:
lorentz 2026-04-23 11:06:39 +00:00
parent a4bc1bfe4f
commit f3a9fb6670
6 changed files with 443 additions and 21 deletions

View file

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

View file

@ -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() {
</p>
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 mb-8">
{adminSections.map((section) => {
const Icon = section.icon
return (
@ -123,6 +124,11 @@ export default async function AdminPage() {
)
})}
</div>
<div className="max-w-2xl">
<h2 className="text-lg font-semibold mb-3">Danger Zone</h2>
<CleanRestartButton />
</div>
</div>
)
}

View file

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

View file

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

View file

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

View file

@ -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<string, number>; 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 (
<div className="border rounded-lg p-6 bg-destructive/5 border-destructive/20">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="font-semibold text-base flex items-center gap-2">
<RotateCcw className="h-4 w-4 text-destructive" />
Clean Restart (April 1, 2026+)
</h3>
<p className="text-sm text-muted-foreground mt-1">
Wipes <strong>all tasks</strong> and regenerates only for renewals on or after April 1, 2026.
Workers Comp mod-factor tasks will only be created for WC policies.
</p>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm" disabled={loading}>
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{loading ? 'Running…' : 'Run Clean Restart'}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Wipe and regenerate all tasks?</AlertDialogTitle>
<AlertDialogDescription>
This will <strong>permanently delete every task and assignment</strong> 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.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleRun}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Yes, wipe and regenerate
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{error && (
<p className="mt-3 text-sm text-destructive font-medium">{error}</p>
)}
{result && (
<div className="mt-4 space-y-2">
<p className="text-sm font-medium text-green-700"> Complete</p>
<div className="grid grid-cols-2 gap-x-8 gap-y-1 text-sm">
<span className="text-muted-foreground">Tasks deleted</span>
<span className="font-mono">{result.summary.deletedTasks}</span>
<span className="text-muted-foreground">Groups processed</span>
<span className="font-mono">{result.summary.groupsProcessed}</span>
<span className="text-muted-foreground">Policies processed</span>
<span className="font-mono">{result.summary.policiesProcessed}</span>
<span className="text-muted-foreground">Tasks created</span>
<span className="font-mono font-semibold">{result.summary.totalTasksCreated}</span>
</div>
</div>
)}
</div>
)
}