Compare commits
2 commits
38365e97b5
...
64ea7800ed
| Author | SHA1 | Date | |
|---|---|---|---|
| 64ea7800ed | |||
| e7da36e7e7 |
7 changed files with 316 additions and 97 deletions
|
|
@ -0,0 +1,118 @@
|
||||||
|
# Task Change Log & Restore — Design (paused, in progress)
|
||||||
|
|
||||||
|
**Status:** Paused mid-brainstorm at user's request. Overall architecture shape is
|
||||||
|
approved. Rollout specifics (which existing code paths need wrapping, and the
|
||||||
|
hourly rollup query) are NOT yet designed — that's the resume point.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Horizon has no way to see the history of a task's state over time, or to undo
|
||||||
|
the effect of a specific automation run (sync, auto-generate) or a bad manual
|
||||||
|
edit. This came up directly after a manual backfill script created ~86 tasks
|
||||||
|
for two clients (Sultan Trans, Dagostino Electronic Services) by writing raw
|
||||||
|
SQL — a change with no audit trail beyond a generic `audit_logs` row.
|
||||||
|
|
||||||
|
Goal: an oversight system that (a) snapshots open-task state hourly for
|
||||||
|
trend/impact visibility per advocate/client/etc, and (b) lets an Admin
|
||||||
|
literally restore tasks to a prior state, scoped to a specific run or trigger
|
||||||
|
— covering both automation-triggered changes and manual UI edits.
|
||||||
|
|
||||||
|
## Decisions made so far
|
||||||
|
|
||||||
|
| Question | Decision |
|
||||||
|
|---|---|
|
||||||
|
| Primary use case | Literal restore capability, not just diffing |
|
||||||
|
| Restore scope | Scoped to a specific run/trigger, not whole-system point-in-time |
|
||||||
|
| Coverage | Both automation (sync, auto-generate, admin backfills) AND manual UI edits |
|
||||||
|
| Retention | 1 year, then prune |
|
||||||
|
| Restore access | Admin-only, via a page listing runs (pick one, restore just those tasks) |
|
||||||
|
| Capture mechanism | Postgres DB trigger (not app-level-only helper) — see rationale below |
|
||||||
|
|
||||||
|
**Why a DB trigger over an app-level helper:** an app-level helper (a shared
|
||||||
|
TS function all mutation code paths call) is simpler and more idiomatic for
|
||||||
|
this codebase, but silently misses any write that doesn't go through it — like
|
||||||
|
today's raw-SQL backfill script. A trigger on the `tasks` / `task_assignments`
|
||||||
|
tables fires on every INSERT/UPDATE/DELETE regardless of what wrote the row,
|
||||||
|
so nothing is invisible by construction. Run-tagging (which run caused a
|
||||||
|
given change) is layered on top via a Postgres session variable
|
||||||
|
(`SET LOCAL app.run_id`) that callers set before writing; if unset, the event
|
||||||
|
is still captured, just tagged `UNTRACKED` instead of getting a rich label.
|
||||||
|
|
||||||
|
## Architecture (approved shape)
|
||||||
|
|
||||||
|
### Data model
|
||||||
|
|
||||||
|
**`changes_runs`** — groups events from one logical action:
|
||||||
|
- `id`, `trigger_type` (`MANUAL_EDIT` / `SYNC` / `AUTO_GENERATE` / `CRON` /
|
||||||
|
`ADMIN_BACKFILL` / `RESTORE` / `UNTRACKED`), `triggered_by` (nullable FK →
|
||||||
|
users), `started_at`, `completed_at`, `description`
|
||||||
|
|
||||||
|
**`task_change_events`** — one row per actual field change on a `tasks` or
|
||||||
|
`task_assignments` row (not per-request, per-column):
|
||||||
|
- `id`, `task_id`, `run_id` (nullable FK → `changes_runs`), `change_type`
|
||||||
|
(`INSERT`/`UPDATE`/`DELETE`), `old_values` (jsonb, null for INSERT),
|
||||||
|
`new_values` (jsonb, null for DELETE), `changed_at`
|
||||||
|
|
||||||
|
**`task_rollup_snapshots`** — the hourly oversight/trend view from the
|
||||||
|
original ask, kept separate from restore data since it's just for
|
||||||
|
trend-watching, not restore:
|
||||||
|
- one row per (hour, advocate | client | department | status) combo with an
|
||||||
|
open-task count. Cheap aggregate, not full-row duplication.
|
||||||
|
|
||||||
|
### Capture mechanism
|
||||||
|
`AFTER INSERT OR UPDATE OR DELETE` trigger on `tasks` and `task_assignments`
|
||||||
|
writes to `task_change_events`. Skips no-op updates (`OLD` = `NEW`). `run_id`
|
||||||
|
comes from the session variable set by the caller; unset → `UNTRACKED`.
|
||||||
|
|
||||||
|
### Restore flow
|
||||||
|
Admin-only page lists recent runs (trigger type, who/what, timestamp, task
|
||||||
|
count affected). Picking a run and confirming:
|
||||||
|
- For each task touched in that run, take the *earliest* event in that run
|
||||||
|
(state immediately before the run started).
|
||||||
|
- `INSERT` → delete the task. `DELETE` → recreate from `old_values`.
|
||||||
|
`UPDATE` → write `old_values` back.
|
||||||
|
- The restore itself runs through the same capture path, becoming its own new
|
||||||
|
run (`trigger_type = RESTORE`) — so a restore is itself visible in the run
|
||||||
|
list and can be undone.
|
||||||
|
|
||||||
|
### Retention
|
||||||
|
Nightly prune job (same systemd-timer + `x-cron-secret` pattern as the
|
||||||
|
existing `ondeck-sync.timer` → `/api/cron/sync`) deletes
|
||||||
|
`task_change_events` and `task_rollup_snapshots` older than 1 year.
|
||||||
|
|
||||||
|
## Existing patterns this builds on
|
||||||
|
- Scheduling: `ondeck-sync.timer` (systemd) → `curl -X POST
|
||||||
|
http://localhost:3000/api/cron/sync -H 'x-cron-secret: ...'`. New cron
|
||||||
|
endpoints (hourly rollup, nightly prune) should follow this exact pattern.
|
||||||
|
- `audit_logs` (generic action log) and `task_audits` (an unrelated
|
||||||
|
document-matching audit feature) already exist — this new system is
|
||||||
|
distinct from both; avoid name collisions (`task_audits` is taken).
|
||||||
|
- Task mutation surface to eventually wrap/verify against the trigger:
|
||||||
|
task edit modal, complete/NA/cancel dialogs, bulk assign/transfer routes,
|
||||||
|
`src/lib/sync/auto-generate.ts`, `src/lib/sync/sync-engine.ts`
|
||||||
|
(`runPostSyncAutomation`), the three admin `generate-*-tasks` routes,
|
||||||
|
`/api/tasks/generate-and-assign`.
|
||||||
|
|
||||||
|
## Open / not yet designed (resume point)
|
||||||
|
|
||||||
|
1. **Rollout specifics** — exact list of every existing task-mutation code
|
||||||
|
path, and whether/how each needs to set `SET LOCAL app.run_id` (vs relying
|
||||||
|
on the trigger's `UNTRACKED` fallback for lower-value paths).
|
||||||
|
2. **Hourly rollup query** — the exact aggregation query/schema for
|
||||||
|
`task_rollup_snapshots` (dimensions: advocate, client, department, status;
|
||||||
|
need to confirm which combos matter for the oversight dashboard).
|
||||||
|
3. Prisma modeling of trigger-based tables (Prisma doesn't manage triggers
|
||||||
|
natively — will need a raw SQL migration for the trigger + trigger
|
||||||
|
function, with the tables themselves modeled normally in
|
||||||
|
`prisma/schema.prisma`).
|
||||||
|
4. UI for the Admin run-list/restore page — not discussed at all yet.
|
||||||
|
5. Whether restoring a task whose *client or policy itself* was deleted since
|
||||||
|
the run needs special handling (FK now dangling).
|
||||||
|
6. Spec self-review and user sign-off on the written spec (paused before this
|
||||||
|
step — do this when resuming, before moving to implementation planning).
|
||||||
|
|
||||||
|
## Next step on resume
|
||||||
|
Continue the brainstorming flow from "rollout specifics" (item 1 above) —
|
||||||
|
enumerate every task-mutation code path and decide the wrapping approach —
|
||||||
|
then finish the spec self-review and get explicit sign-off before invoking
|
||||||
|
the writing-plans skill.
|
||||||
|
|
@ -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 })
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
11
ondeck/src/lib/sync/task-due-date.ts
Normal file
11
ondeck/src/lib/sync/task-due-date.ts
Normal 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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue