Clean restart: policyTypeFilter on templates, wipe+regenerate Apr 1+ endpoint, admin UI button
This commit is contained in:
parent
a4bc1bfe4f
commit
f3a9fb6670
6 changed files with 443 additions and 21 deletions
|
|
@ -299,6 +299,7 @@ model TaskTemplate {
|
|||
displayOrder Int? @map("display_order")
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
299
ondeck/src/app/api/admin/clean-restart/route.ts
Normal file
299
ondeck/src/app/api/admin/clean-restart/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -174,7 +174,12 @@ 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 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 {
|
||||
|
|
@ -192,6 +197,7 @@ export async function POST(request: NextRequest) {
|
|||
}
|
||||
})
|
||||
|
||||
if (tasksToCreate.length === 0) continue
|
||||
const created = await prisma.task.createMany({ data: tasksToCreate })
|
||||
policyTasksCreated += created.count
|
||||
|
||||
|
|
|
|||
107
ondeck/src/components/admin/clean-restart-button.tsx
Normal file
107
ondeck/src/components/admin/clean-restart-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue