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:
parent
36076ce90d
commit
00b5fab1ba
2 changed files with 327 additions and 0 deletions
292
ondeck/src/app/api/admin/gap-fill/route.ts
Normal file
292
ondeck/src/app/api/admin/gap-fill/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue