fix(tasks): skip generating tasks with computed due dates in the past

auto-generate.ts and all three manual task-generation routes computed
due dates purely from a policy/group anchor date plus a template's
daysOffset, with no check against the current date. Surfaced when a
manual backfill for two clients created tasks anchored to an already-
expired policy term, producing due dates back in 2024.

Add isRelevantDueDate() and apply it at every task-creation call site
so no template-generated task is ever born already overdue.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
lorentz 2026-07-17 20:59:02 +00:00
parent e7da36e7e7
commit 64ea7800ed
6 changed files with 198 additions and 97 deletions

View file

@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth' import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db' import { prisma } from '@/lib/db'
import { isRelevantDueDate } from '@/lib/sync/task-due-date'
/** /**
* POST /api/policy-groups/[id]/generate-policy-tasks * POST /api/policy-groups/[id]/generate-policy-tasks
@ -89,6 +90,7 @@ export async function POST(
createdBy: (session.user as any).id, createdBy: (session.user as any).id,
} }
}) })
.filter((task) => isRelevantDueDate(task.dueDate))
if (tasksToCreate.length === 0) { if (tasksToCreate.length === 0) {
return NextResponse.json({ created: 0 }) return NextResponse.json({ created: 0 })

View file

@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth' import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db' import { prisma } from '@/lib/db'
import { isRelevantDueDate } from '@/lib/sync/task-due-date'
/** /**
* POST /api/policy-groups/[id]/generate-tasks * POST /api/policy-groups/[id]/generate-tasks
@ -78,26 +79,36 @@ export async function POST(
const renewalDate = new Date(group.renewalDate) const renewalDate = new Date(group.renewalDate)
const tasksToCreate = templates.map((template) => { const tasksToCreate = templates
const dueDate = new Date(renewalDate) .map((template) => {
dueDate.setDate(dueDate.getDate() + template.daysOffset) const dueDate = new Date(renewalDate)
dueDate.setDate(dueDate.getDate() + template.daysOffset)
return { return {
title: template.name, title: template.name,
description: template.description, description: template.description,
department: template.department, department: template.department,
timing: template.timing, timing: template.timing,
daysOffset: template.daysOffset, daysOffset: template.daysOffset,
dueDate, dueDate,
status: 'NOT_STARTED' as const, status: 'NOT_STARTED' as const,
priority: template.defaultPriority, priority: template.defaultPriority,
taskGroup: template.taskGroup, taskGroup: template.taskGroup,
clientId: group.clientId, clientId: group.clientId,
policyGroupId: group.id, policyGroupId: group.id,
templateId: template.id, templateId: template.id,
createdBy: (session.user as any).id, createdBy: (session.user as any).id,
} }
}) })
.filter((task) => isRelevantDueDate(task.dueDate))
if (tasksToCreate.length === 0) {
return NextResponse.json({
created: 0,
skipped: alreadyGeneratedTemplateIds.length,
message: 'No new templates produced a relevant (today-or-later) due date',
})
}
const result = await prisma.task.createMany({ const result = await prisma.task.createMany({
data: tasksToCreate as any[], data: tasksToCreate as any[],
@ -111,7 +122,7 @@ export async function POST(
entityId: group.id, entityId: group.id,
newValues: { newValues: {
tasksCreated: result.count, tasksCreated: result.count,
templatesUsed: templates.map((t) => t.name), templatesUsed: tasksToCreate.map((t) => t.title),
renewalDate: group.renewalDate, renewalDate: group.renewalDate,
}, },
}, },
@ -120,14 +131,7 @@ export async function POST(
return NextResponse.json({ return NextResponse.json({
created: result.count, created: result.count,
skipped: alreadyGeneratedTemplateIds.length, skipped: alreadyGeneratedTemplateIds.length,
templates: templates.map((t) => ({ templates: tasksToCreate.map((t) => ({ name: t.title, dueDate: t.dueDate })),
name: t.name,
dueDate: new Date(
new Date(group.renewalDate).setDate(
new Date(group.renewalDate).getDate() + t.daysOffset
)
),
})),
}) })
} catch (error) { } catch (error) {
console.error('Generate tasks error:', error) console.error('Generate tasks error:', error)

View file

@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth' import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db' import { prisma } from '@/lib/db'
import { isRelevantDueDate } from '@/lib/sync/task-due-date'
/** /**
* POST /api/tasks/generate-and-assign * POST /api/tasks/generate-and-assign
@ -96,25 +97,28 @@ export async function POST(request: NextRequest) {
for (const group of groups) { for (const group of groups) {
const renewalDate = new Date(group.renewalDate) const renewalDate = new Date(group.renewalDate)
const tasksToCreate = groupTemplates.map((template) => { const tasksToCreate = groupTemplates
const dueDate = new Date(renewalDate) .map((template) => {
dueDate.setDate(dueDate.getDate() + template.daysOffset) const dueDate = new Date(renewalDate)
return { dueDate.setDate(dueDate.getDate() + template.daysOffset)
title: template.name, return {
description: template.description, title: template.name,
department: template.department, description: template.description,
timing: template.timing, department: template.department,
daysOffset: template.daysOffset, timing: template.timing,
dueDate, daysOffset: template.daysOffset,
status: 'NOT_STARTED' as const, dueDate,
priority: template.defaultPriority, status: 'NOT_STARTED' as const,
clientId: group.clientId, priority: template.defaultPriority,
policyGroupId: group.id, clientId: group.clientId,
templateId: template.id, policyGroupId: group.id,
createdBy: (session.user as any).id, templateId: template.id,
} createdBy: (session.user as any).id,
}) }
})
.filter((task) => isRelevantDueDate(task.dueDate))
if (tasksToCreate.length === 0) continue
const created = await prisma.task.createMany({ data: tasksToCreate }) const created = await prisma.task.createMany({ data: tasksToCreate })
tasksCreated += created.count tasksCreated += created.count
@ -146,25 +150,28 @@ export async function POST(request: NextRequest) {
for (const policy of policies) { for (const policy of policies) {
const anchorDate = new Date(policy.expirationDate) const anchorDate = new Date(policy.expirationDate)
anchorDate.setDate(anchorDate.getDate() + 1) // renewal date = expiration + 1 anchorDate.setDate(anchorDate.getDate() + 1) // renewal date = expiration + 1
const tasksToCreate = policyTemplates.map((template) => { const tasksToCreate = policyTemplates
const dueDate = new Date(anchorDate) .map((template) => {
dueDate.setDate(dueDate.getDate() + template.daysOffset) const dueDate = new Date(anchorDate)
return { dueDate.setDate(dueDate.getDate() + template.daysOffset)
title: template.name, return {
description: template.description, title: template.name,
department: template.department, description: template.description,
timing: template.timing, department: template.department,
daysOffset: template.daysOffset, timing: template.timing,
dueDate, daysOffset: template.daysOffset,
status: 'NOT_STARTED' as const, dueDate,
priority: template.defaultPriority, status: 'NOT_STARTED' as const,
clientId: policy.clientId, priority: template.defaultPriority,
policyId: policy.id, clientId: policy.clientId,
templateId: template.id, policyId: policy.id,
createdBy: (session.user as any).id, templateId: template.id,
} createdBy: (session.user as any).id,
}) }
})
.filter((task) => isRelevantDueDate(task.dueDate))
if (tasksToCreate.length === 0) continue
const created = await prisma.task.createMany({ data: tasksToCreate }) const created = await prisma.task.createMany({ data: tasksToCreate })
tasksCreated += created.count tasksCreated += created.count
@ -219,6 +226,7 @@ export async function POST(request: NextRequest) {
const dueDate = new Date(anchorDate) const dueDate = new Date(anchorDate)
dueDate.setDate(dueDate.getDate() + template.daysOffset) dueDate.setDate(dueDate.getDate() + template.daysOffset)
if (!isRelevantDueDate(dueDate)) continue
const created = await prisma.task.create({ const created = await prisma.task.create({
data: { data: {

View file

@ -129,3 +129,71 @@ describe('runAutoGenerate — CLIENT-level task generation', () => {
expect(result.clientTasksCreated).toBe(1) expect(result.clientTasksCreated).toBe(1)
}) })
}) })
describe('runAutoGenerate — past-due task filtering', () => {
it('does not create a POLICY-level task whose computed due date is already in the past', async () => {
// "now" is fixed at 2026-07-09. This policy's term ended over a year ago
// (2025-05-19), so every template anchored to it computes a due date well
// before "now" and must be skipped entirely.
const policy = {
id: 'policy-expired-term',
clientId: 'client-dagostino',
policyType: 'Package',
expirationDate: new Date('2025-05-19'),
client: { designationId: null, designation2Id: null, claimsAdvocateId: null },
}
mockPolicyFindMany.mockResolvedValue([policy])
mockTaskTemplateFindMany.mockResolvedValue([
{
id: 'template-onboarding',
name: 'SHAPE Onboarding Checklist',
description: null,
department: 'Commercial Lines',
timing: 'PRE_RENEWAL',
daysOffset: -337,
defaultPriority: 'NORMAL',
level: 'POLICY',
policyTypeFilter: null,
},
])
const result = await runAutoGenerate(config)
expect(mockTaskCreateMany).not.toHaveBeenCalled()
expect(result.policyTasksCreated).toBe(0)
})
it('still creates POLICY-level tasks whose computed due date is today or later, for the same policy', async () => {
const policy = {
id: 'policy-current-term',
clientId: 'client-sultan',
policyType: 'Package',
expirationDate: new Date('2027-06-10'),
client: { designationId: null, designation2Id: null, claimsAdvocateId: null },
}
mockPolicyFindMany.mockResolvedValue([policy])
mockTaskTemplateFindMany.mockResolvedValue([
{
id: 'template-loss-runs',
name: 'Request 125 day loss runs',
description: null,
department: 'Commercial Lines',
timing: 'PRE_RENEWAL',
daysOffset: -125,
defaultPriority: 'NORMAL',
level: 'POLICY',
policyTypeFilter: null,
},
])
mockTaskCreateMany.mockResolvedValue({ count: 1 })
const result = await runAutoGenerate(config)
expect(mockTaskCreateMany).toHaveBeenCalledWith(
expect.objectContaining({
data: [expect.objectContaining({ templateId: 'template-loss-runs' })],
})
)
expect(result.policyTasksCreated).toBe(1)
})
})

View file

@ -1,5 +1,6 @@
import { prisma } from '@/lib/db' import { prisma } from '@/lib/db'
import { AUTOMATION_GO_LIVE_AT, type AutomationConfig } from './automation-config' import { AUTOMATION_GO_LIVE_AT, type AutomationConfig } from './automation-config'
import { isRelevantDueDate } from './task-due-date'
const DAY_MS = 24 * 60 * 60 * 1000 const DAY_MS = 24 * 60 * 60 * 1000
@ -107,24 +108,27 @@ export async function runAutoGenerate(config: AutomationConfig): Promise<AutoGen
if (templates.length === 0) continue if (templates.length === 0) continue
const renewalDate = new Date(group.renewalDate) const renewalDate = new Date(group.renewalDate)
const tasksToCreate = templates.map((template) => { const tasksToCreate = templates
const dueDate = new Date(renewalDate) .map((template) => {
dueDate.setDate(dueDate.getDate() + template.daysOffset) const dueDate = new Date(renewalDate)
return { dueDate.setDate(dueDate.getDate() + template.daysOffset)
title: template.name, return {
description: template.description, title: template.name,
department: template.department, description: template.description,
timing: template.timing, department: template.department,
daysOffset: template.daysOffset, timing: template.timing,
dueDate, daysOffset: template.daysOffset,
status: 'NOT_STARTED' as const, dueDate,
priority: template.defaultPriority, status: 'NOT_STARTED' as const,
clientId: group.clientId, priority: template.defaultPriority,
policyGroupId: group.id, clientId: group.clientId,
templateId: template.id, policyGroupId: group.id,
} templateId: template.id,
}) }
})
.filter((task) => isRelevantDueDate(task.dueDate))
if (tasksToCreate.length === 0) continue
const created = await prisma.task.createMany({ data: tasksToCreate }) const created = await prisma.task.createMany({ data: tasksToCreate })
groupTasksCreated += created.count groupTasksCreated += created.count
@ -198,6 +202,7 @@ export async function runAutoGenerate(config: AutomationConfig): Promise<AutoGen
templateId: template.id, templateId: template.id,
} }
}) })
.filter((task) => isRelevantDueDate(task.dueDate))
if (tasksToCreate.length === 0) continue if (tasksToCreate.length === 0) continue
const created = await prisma.task.createMany({ data: tasksToCreate }) const created = await prisma.task.createMany({ data: tasksToCreate })
@ -282,23 +287,26 @@ export async function runAutoGenerate(config: AutomationConfig): Promise<AutoGen
if (allDates.length === 0) continue if (allDates.length === 0) continue
const anchorDate = new Date(Math.min(...allDates)) const anchorDate = new Date(Math.min(...allDates))
const tasksToCreate = clientTemplates.map((template) => { const tasksToCreate = clientTemplates
const dueDate = new Date(anchorDate) .map((template) => {
dueDate.setDate(dueDate.getDate() + template.daysOffset) const dueDate = new Date(anchorDate)
return { dueDate.setDate(dueDate.getDate() + template.daysOffset)
title: template.name, return {
description: template.description, title: template.name,
department: template.department, description: template.description,
timing: template.timing, department: template.department,
daysOffset: template.daysOffset, timing: template.timing,
dueDate, daysOffset: template.daysOffset,
status: 'NOT_STARTED' as const, dueDate,
priority: template.defaultPriority, status: 'NOT_STARTED' as const,
clientId: client.id, priority: template.defaultPriority,
templateId: template.id, clientId: client.id,
} templateId: template.id,
}) }
})
.filter((task) => isRelevantDueDate(task.dueDate))
if (tasksToCreate.length === 0) continue
const created = await prisma.task.createMany({ data: tasksToCreate }) const created = await prisma.task.createMany({ data: tasksToCreate })
clientTasksCreated += created.count clientTasksCreated += created.count

View file

@ -0,0 +1,11 @@
/**
* A template-generated task whose computed due date already fell in the past
* by the time generation ran (e.g. an already-superseded policy term, or a
* long lead-time offset generated late into the term) provides no value
* it's born overdue with no chance anyone could have acted on it in time.
*/
export function isRelevantDueDate(dueDate: Date, now: Date = new Date()): boolean {
const startOfToday = new Date(now)
startOfToday.setHours(0, 0, 0, 0)
return dueDate >= startOfToday
}