diff --git a/ondeck/docs/superpowers/specs/2026-07-17-task-change-log-and-restore-design.md b/ondeck/docs/superpowers/specs/2026-07-17-task-change-log-and-restore-design.md deleted file mode 100644 index 945f88c..0000000 --- a/ondeck/docs/superpowers/specs/2026-07-17-task-change-log-and-restore-design.md +++ /dev/null @@ -1,118 +0,0 @@ -# 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. diff --git a/ondeck/src/app/api/policy-groups/[id]/generate-policy-tasks/route.ts b/ondeck/src/app/api/policy-groups/[id]/generate-policy-tasks/route.ts index f437a28..ffedb60 100644 --- a/ondeck/src/app/api/policy-groups/[id]/generate-policy-tasks/route.ts +++ b/ondeck/src/app/api/policy-groups/[id]/generate-policy-tasks/route.ts @@ -2,7 +2,6 @@ import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/db' -import { isRelevantDueDate } from '@/lib/sync/task-due-date' /** * POST /api/policy-groups/[id]/generate-policy-tasks @@ -90,7 +89,6 @@ export async function POST( createdBy: (session.user as any).id, } }) - .filter((task) => isRelevantDueDate(task.dueDate)) if (tasksToCreate.length === 0) { return NextResponse.json({ created: 0 }) diff --git a/ondeck/src/app/api/policy-groups/[id]/generate-tasks/route.ts b/ondeck/src/app/api/policy-groups/[id]/generate-tasks/route.ts index d961a82..6de01af 100644 --- a/ondeck/src/app/api/policy-groups/[id]/generate-tasks/route.ts +++ b/ondeck/src/app/api/policy-groups/[id]/generate-tasks/route.ts @@ -2,7 +2,6 @@ import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/db' -import { isRelevantDueDate } from '@/lib/sync/task-due-date' /** * POST /api/policy-groups/[id]/generate-tasks @@ -79,36 +78,26 @@ export async function POST( const renewalDate = new Date(group.renewalDate) - const tasksToCreate = templates - .map((template) => { - const dueDate = new Date(renewalDate) - dueDate.setDate(dueDate.getDate() + template.daysOffset) + 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, - taskGroup: template.taskGroup, - clientId: group.clientId, - policyGroupId: group.id, - templateId: template.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', - }) - } + 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, + taskGroup: template.taskGroup, + clientId: group.clientId, + policyGroupId: group.id, + templateId: template.id, + createdBy: (session.user as any).id, + } + }) const result = await prisma.task.createMany({ data: tasksToCreate as any[], @@ -122,7 +111,7 @@ export async function POST( entityId: group.id, newValues: { tasksCreated: result.count, - templatesUsed: tasksToCreate.map((t) => t.title), + templatesUsed: templates.map((t) => t.name), renewalDate: group.renewalDate, }, }, @@ -131,7 +120,14 @@ export async function POST( return NextResponse.json({ created: result.count, skipped: alreadyGeneratedTemplateIds.length, - templates: tasksToCreate.map((t) => ({ name: t.title, dueDate: t.dueDate })), + templates: templates.map((t) => ({ + name: t.name, + dueDate: new Date( + new Date(group.renewalDate).setDate( + new Date(group.renewalDate).getDate() + t.daysOffset + ) + ), + })), }) } catch (error) { console.error('Generate tasks error:', error) diff --git a/ondeck/src/app/api/tasks/generate-and-assign/route.ts b/ondeck/src/app/api/tasks/generate-and-assign/route.ts index 2e8594c..34a9880 100644 --- a/ondeck/src/app/api/tasks/generate-and-assign/route.ts +++ b/ondeck/src/app/api/tasks/generate-and-assign/route.ts @@ -2,7 +2,6 @@ import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/db' -import { isRelevantDueDate } from '@/lib/sync/task-due-date' /** * POST /api/tasks/generate-and-assign @@ -97,28 +96,25 @@ export async function POST(request: NextRequest) { for (const group of groups) { const renewalDate = new Date(group.renewalDate) - const tasksToCreate = groupTemplates - .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, - createdBy: (session.user as any).id, - } - }) - .filter((task) => isRelevantDueDate(task.dueDate)) + const tasksToCreate = groupTemplates.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, + createdBy: (session.user as any).id, + } + }) - if (tasksToCreate.length === 0) continue const created = await prisma.task.createMany({ data: tasksToCreate }) tasksCreated += created.count @@ -150,28 +146,25 @@ export async function POST(request: NextRequest) { for (const policy of policies) { const anchorDate = new Date(policy.expirationDate) anchorDate.setDate(anchorDate.getDate() + 1) // renewal date = expiration + 1 - const tasksToCreate = policyTemplates - .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, - createdBy: (session.user as any).id, - } - }) - .filter((task) => isRelevantDueDate(task.dueDate)) + const tasksToCreate = policyTemplates.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, + createdBy: (session.user as any).id, + } + }) - if (tasksToCreate.length === 0) continue const created = await prisma.task.createMany({ data: tasksToCreate }) tasksCreated += created.count @@ -226,7 +219,6 @@ export async function POST(request: NextRequest) { const dueDate = new Date(anchorDate) dueDate.setDate(dueDate.getDate() + template.daysOffset) - if (!isRelevantDueDate(dueDate)) continue const created = await prisma.task.create({ data: { diff --git a/ondeck/src/lib/sync/__tests__/auto-generate.test.ts b/ondeck/src/lib/sync/__tests__/auto-generate.test.ts index f888c0f..eaf2732 100644 --- a/ondeck/src/lib/sync/__tests__/auto-generate.test.ts +++ b/ondeck/src/lib/sync/__tests__/auto-generate.test.ts @@ -129,71 +129,3 @@ describe('runAutoGenerate — CLIENT-level task generation', () => { 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) - }) -}) diff --git a/ondeck/src/lib/sync/auto-generate.ts b/ondeck/src/lib/sync/auto-generate.ts index c6f5bc8..a63d5b2 100644 --- a/ondeck/src/lib/sync/auto-generate.ts +++ b/ondeck/src/lib/sync/auto-generate.ts @@ -1,6 +1,5 @@ import { prisma } from '@/lib/db' import { AUTOMATION_GO_LIVE_AT, type AutomationConfig } from './automation-config' -import { isRelevantDueDate } from './task-due-date' const DAY_MS = 24 * 60 * 60 * 1000 @@ -108,27 +107,24 @@ export async function runAutoGenerate(config: AutomationConfig): Promise { - 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, - } - }) - .filter((task) => isRelevantDueDate(task.dueDate)) + 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, + } + }) - if (tasksToCreate.length === 0) continue const created = await prisma.task.createMany({ data: tasksToCreate }) groupTasksCreated += created.count @@ -202,7 +198,6 @@ export async function runAutoGenerate(config: AutomationConfig): Promise isRelevantDueDate(task.dueDate)) if (tasksToCreate.length === 0) continue const created = await prisma.task.createMany({ data: tasksToCreate }) @@ -287,26 +282,23 @@ export async function runAutoGenerate(config: AutomationConfig): Promise { - 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: client.id, - templateId: template.id, - } - }) - .filter((task) => isRelevantDueDate(task.dueDate)) + const tasksToCreate = clientTemplates.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: client.id, + templateId: template.id, + } + }) - if (tasksToCreate.length === 0) continue const created = await prisma.task.createMany({ data: tasksToCreate }) clientTasksCreated += created.count diff --git a/ondeck/src/lib/sync/task-due-date.ts b/ondeck/src/lib/sync/task-due-date.ts deleted file mode 100644 index 12c7a36..0000000 --- a/ondeck/src/lib/sync/task-due-date.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * 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 -}