Add Gap Fill: delete 507 orphaned tasks, new /api/admin/gap-fill endpoint, button in shape-import panel

- Deletes tasks where POLICY/RENEWAL_GROUP template has no policy_id/policy_group_id
- Gap Fill creates missing tasks per group/policy/client without overwriting existing ones
- Existence check within 5-day window prevents duplicates
- Gap Fill button added to shape-import admin panel with inline result summary
This commit is contained in:
lorentz 2026-04-13 19:40:34 +00:00
parent 36076ce90d
commit 00b5fab1ba
2 changed files with 327 additions and 0 deletions

View file

@ -0,0 +1,292 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db'
/**
* POST /api/admin/gap-fill
* Admin-only. Generates missing tasks for policy groups, ungrouped policies,
* and clients without overwriting any existing tasks (skipDuplicates + existence checks).
*/
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[] = []
let groupTasksCreated = 0
let policyTasksCreated = 0
let clientTasksCreated = 0
const errors: string[] = []
// ── 1. Group-level tasks ────────────────────────────────────────────────
const groups = await prisma.policyGroup.findMany({
where: { 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'] as any[] },
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)
for (const template of templates) {
const dueDate = new Date(renewalDate)
dueDate.setDate(dueDate.getDate() + template.daysOffset)
// Check if task already exists for this group+template (within 5 day window)
const windowMs = 5 * 86400 * 1000
const existing = await prisma.task.findFirst({
where: {
clientId: group.clientId,
templateId: template.id,
policyGroupId: group.id,
dueDate: { gte: new Date(dueDate.getTime() - windowMs), lte: new Date(dueDate.getTime() + windowMs) },
},
select: { id: true },
})
if (existing) continue
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: group.clientId,
policyGroupId: group.id,
templateId: template.id,
},
select: { id: true },
})
groupTasksCreated++
if (group.client.claimsAdvocateId) {
await prisma.taskAssignment.create({
data: { taskId: created.id, userId: group.client.claimsAdvocateId },
})
}
}
} catch (err: any) {
errors.push(`Group ${group.id}: ${err.message}`)
}
}
log.push(`Group tasks created: ${groupTasksCreated}`)
// ── 2. Policy-level tasks (ungrouped policies only) ─────────────────────
const ungroupedPolicies = await prisma.policy.findMany({
where: {
policyGroupId: null,
expirationDate: { gte: new Date() },
status: { notIn: ['Cancelled', 'Expired', 'Non-Renewed', 'Rewritten', 'Not taken'] as any[] },
},
select: {
id: true,
expirationDate: true,
clientId: true,
client: { select: { designationId: true, designation2Id: true, claimsAdvocateId: true } },
},
})
for (const policy of ungroupedPolicies) {
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'] as any[] },
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)
for (const template of templates) {
const dueDate = new Date(anchorDate)
dueDate.setDate(dueDate.getDate() + template.daysOffset)
const windowMs = 5 * 86400 * 1000
const existing = await prisma.task.findFirst({
where: {
clientId: policy.clientId,
templateId: template.id,
policyId: policy.id,
dueDate: { gte: new Date(dueDate.getTime() - windowMs), lte: new Date(dueDate.getTime() + windowMs) },
},
select: { id: true },
})
if (existing) continue
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: policy.clientId,
policyId: policy.id,
templateId: template.id,
},
select: { id: true },
})
policyTasksCreated++
if (policy.client.claimsAdvocateId) {
await prisma.taskAssignment.create({
data: { taskId: created.id, userId: policy.client.claimsAdvocateId },
})
}
}
} catch (err: any) {
errors.push(`Policy ${policy.id}: ${err.message}`)
}
}
log.push(`Policy tasks created: ${policyTasksCreated}`)
// ── 3. Client-level tasks ───────────────────────────────────────────────
const allClientIds = [...new Set([
...groups.map((g) => g.clientId),
...ungroupedPolicies.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: { select: { renewalDate: true } },
policies: { where: { policyGroupId: null }, 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' as any,
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 windowMs = 5 * 86400 * 1000
const existing = await prisma.task.findFirst({
where: {
clientId,
templateId: template.id,
policyId: null,
policyGroupId: null,
dueDate: { gte: new Date(dueDate.getTime() - windowMs), lte: new Date(dueDate.getTime() + windowMs) },
},
select: { id: true },
})
if (existing) continue
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}`)
return NextResponse.json({
success: true,
summary: {
groupTasksCreated,
policyTasksCreated,
clientTasksCreated,
totalCreated: groupTasksCreated + policyTasksCreated + clientTasksCreated,
errors: errors.length,
},
log,
errors: errors.length > 0 ? errors.slice(0, 20) : undefined,
})
} catch (error: any) {
console.error('Gap fill error:', error)
return NextResponse.json({ error: 'Internal server error', detail: error.message }, { status: 500 })
}
}

View file

@ -30,6 +30,7 @@ import {
FolderOpen,
AlertTriangle,
RefreshCw,
Zap,
} from 'lucide-react'
const DEFAULT_DRIVE_ID = 'b!OYuzIexQkkOvfEPyMJPzzZHfzTrOCOdPhTWgTlzKs6M0ZWVrAc6LR4LjWl4QFEzm'
@ -99,6 +100,8 @@ export function ShapeImportPanel({ initialRuns }: { initialRuns: Run[] }) {
const [viewingRunId, setViewingRunId] = useState<string | null>(null)
const [confirmOpen, setConfirmOpen] = useState(false)
const [starting, setStarting] = useState(false)
const [gapFillRunning, setGapFillRunning] = useState(false)
const [gapFillResult, setGapFillResult] = useState<Record<string, any> | null>(null)
const logRef = useRef<HTMLPreElement>(null)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
@ -389,8 +392,40 @@ export function ShapeImportPanel({ initialRuns }: { initialRuns: Run[] }) {
<AlertTriangle className="h-4 w-4" />
Execute Import
</Button>
<Button
onClick={async () => {
setGapFillRunning(true)
setGapFillResult(null)
try {
const res = await fetch('/api/admin/gap-fill', { method: 'POST' })
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Gap fill failed')
setGapFillResult(data.summary)
toast.success(`Gap Fill: ${data.summary.totalCreated} tasks created`)
} catch (err: any) {
toast.error(err.message)
} finally {
setGapFillRunning(false)
}
}}
disabled={gapFillRunning || isRunning}
variant="outline"
className="gap-2"
>
{gapFillRunning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Zap className="h-4 w-4" />}
Gap Fill
</Button>
</div>
{gapFillResult && (
<div className="text-sm p-3 rounded-lg bg-muted/40 space-y-1">
<p className="font-medium">Gap Fill complete</p>
<p className="text-muted-foreground">Group tasks: {gapFillResult.groupTasksCreated} · Policy tasks: {gapFillResult.policyTasksCreated} · Client tasks: {gapFillResult.clientTasksCreated} · Total: {gapFillResult.totalCreated}</p>
{gapFillResult.errors > 0 && <p className="text-destructive">{gapFillResult.errors} errors</p>}
</div>
)}
{isRunning && (
<div className="flex items-center gap-2 text-sm text-blue-600">
<Loader2 className="h-4 w-4 animate-spin" />