diff --git a/dev/horizon-dev-clone-runbook-2026-06-25.md b/dev/horizon-dev-clone-runbook-2026-06-25.md new file mode 100644 index 0000000..c1826d1 --- /dev/null +++ b/dev/horizon-dev-clone-runbook-2026-06-25.md @@ -0,0 +1,51 @@ +# Horizon Dev-Clone Runbook — 2026-06-25 + +Stand up a development instance of Horizon on a **separate Linux host** that runs the +**latest (working-tree) code** against an **exact copy of live production data**. + +- **Target host:** `root@dev02.nb.wulf.cloud` +- **Dev domain:** `https://horizon-dev.seubert.cloud` +- **Source of truth (code):** `/opt/projects/OnDeck/ondeck` (working tree, incl. uncommitted changes) +- **Source of truth (stack):** `/opt/stacks/horizon` +- **Prod DB:** container `horizon-db`, database `horizon`, user `horizon_user` + +## Decisions (locked) +| Decision | Choice | +|---|---| +| Where dev runs | Separate host `dev02.nb.wulf.cloud`, executed hands-on this session | +| Code version | Working tree as-is (includes uncommitted automation-settings / sync changes) | +| Auth | Entra (Azure AD) SSO — dev redirect URI added to app `f6c778cf-c850-47d0-85e7-aff1d1c3288b` | +| Data | Exact `pg_dump` restore of live `horizon` DB (real client PII on dev box) | +| Routing/TLS | New Pangolin **site** = `newt` client on dev host + resource for the dev domain | + +## Target architecture +| Concern | Prod | Dev clone | +|---|---|---| +| App container | `horizon-app` :3000 | `horizon-dev-app` :3000 | +| DB container | `horizon-db` | `horizon-dev-db` (fresh volume) | +| Backup sidecar | `horizon-backup` | optional / off initially | +| Domain | `horizon.seubert.cloud` | `horizon-dev.seubert.cloud` | +| Routing | Pangolin via `newt` | new Pangolin site (`newt` on dev host) + resource | +| Auth | Entra SSO | Entra SSO + dev redirect URI | +| Secrets | prod | regenerated `NEXTAUTH_SECRET`, `DB_PASSWORD` | + +## Execution sequence +1. **Pre-flight (dev host):** Docker + compose v2 present; ≥ ~10 GB free on `/`; outbound 443 reachable (newt → Pangolin). +2. **Pangolin:** create a new site; install/run `newt` on the dev host; add resource `horizon-dev.seubert.cloud` → `http://horizon-dev-app:3000`; verify DNS + cert. (Use the `pangolin` skill.) +3. **Azure AD:** add redirect URI `https://horizon-dev.seubert.cloud/api/auth/callback/azure-ad` to Entra app `f6c778cf-c850-47d0-85e7-aff1d1c3288b`. +4. **Ship code:** rsync `/opt/projects/OnDeck/ondeck` → dev host, excluding `node_modules`, `.next`, `.git`. +5. **Stack + env:** copy `/opt/stacks/horizon`; rename containers to `horizon-dev-*`; set `NEXTAUTH_URL=https://horizon-dev.seubert.cloud`; regenerate `NEXTAUTH_SECRET` and `DB_PASSWORD`; keep Azure creds; keep **read-only** AFW/AMS creds; leave `GRAPH_*` unset. +6. **Data copy:** `pg_dump` live `horizon-db` → gzip → transfer → restore into freshly-initialized `horizon-dev-db`; then `prisma migrate deploy` (or `db push`) to reconcile schema with working-tree code. +7. **Build & start:** `docker compose up -d horizon-dev-db` (wait healthy) → restore dump → `docker compose build horizon-dev-app && docker compose up -d`. +8. **Smoke test:** health endpoint; Entra login; spot-check real client/policy/task data present. + +## Safety guards (dev must not perturb prod or external systems) +- **No external cron trigger** wired on dev → `/api/cron/*` stays gated by `CRON_SECRET`, nothing calls it (no auto-generated tasks / scheduled jobs firing). +- AFW/AMS uses **read-only** user `1100080_RO` → sync cannot mutate the AMS. +- `GRAPH_*` unset (as in prod) → no SharePoint/email writes. +- Dev `NEXTAUTH_SECRET` / `DB_PASSWORD` regenerated so dev secrets ≠ prod. + +## Obligations / cleanup +- **PII:** dev box now holds a full copy of real client PII — ensure `dev02` is secured commensurately and the copy is removed when the dev instance is decommissioned. +- **Temp SSH key:** `claude-horizon-dev-clone-2026-06-25` added to `root@dev02:~/.ssh/authorized_keys`. Remove that line when finished. +- Consider a teardown step (drop dev DB volume, remove Pangolin resource + site, remove Azure redirect URI) when dev is no longer needed. diff --git a/dev/horizon-issues-implementation-2026-05-28.md b/dev/horizon-issues-implementation-2026-05-28.md new file mode 100644 index 0000000..44c49c7 --- /dev/null +++ b/dev/horizon-issues-implementation-2026-05-28.md @@ -0,0 +1,151 @@ +# Horizon Issues Implementation — May 28, 2026 + +**Commit:** `20f7ec1` +**Deployed:** May 28, 2026 ~11:50 PM UTC +**Container rebuild:** Yes — `horizon-app` recreated and restarted + +--- + +## What Was Implemented + +### 1. Tasks Disappearing (High — Bug Fix) +**Symptom:** Mimi's IN_PROGRESS task and other open tasks were vanishing from task lists. + +**Root cause:** The task visibility filter included `policyGroup: { renewalDate: { gte: cutoff } }` with no exemption for open tasks. Any task on a renewal group whose date was older than 7 days was hidden — even if the task was `IN_PROGRESS` or `NOT_STARTED`. + +**Fix:** Added `{ status: { notIn: TERMINAL_STATUSES } }` as a third OR branch so open tasks always show regardless of group renewal date. Only terminal (`COMPLETED`, `CANCELLED`, `NA`) tasks on old groups are filtered out. + +**Files changed:** +- `src/app/(dashboard)/tasks/page.tsx` +- `src/app/(dashboard)/clients/[id]/page.tsx` +- `src/app/api/clients/[id]/tasks/route.ts` + +**Data impact:** None. No task records were modified. The fix only changes what the query returns. + +--- + +### 2. Star Date Change Doesn't Update Task Due Dates (High — Bug Fix) +**Symptom:** When a manager changed the renewal date on a policy group, all open tasks retained their old due dates. + +**Root cause:** `PATCH /api/policy-groups/[id]` saved the new `renewalDate` but never recalculated `dueDate` on existing tasks. Since `dueDate = renewalDate + daysOffset`, the dates silently became wrong. + +**Fix:** After saving the group, the API now fetches all open (`NOT_STARTED`, `IN_PROGRESS`, `BLOCKED`) template-generated tasks in the group and recalculates their `dueDate`. The count of rescheduled tasks is included in the audit log entry and the API response. + +**Files changed:** +- `src/app/api/policy-groups/[id]/route.ts` + +**Data impact:** Future — will update task due dates whenever a manager changes a group's renewal date going forward. Did not retroactively update any existing tasks. + +--- + +### 3. Auto Task Assignment Not Working (BEK, Noor) (High — Feature) +**Symptom:** BEK and Noor were set up 3 days ago but had zero tasks. The auto-generate cron requires all policies to be ≥20 days old before generating tasks. + +**Fix:** When a manager clicks "Save & Complete" in the setup wizard, it now automatically calls `POST /api/policy-groups/[id]/generate-tasks` for every saved group. A loading toast ("Generating tasks…") shows during this step, and the success toast confirms completion. + +**Files changed:** +- `src/app/api/clients/[id]/setup/route.ts` — now returns `groups` array with IDs +- `src/components/renewal-groups/setup-wizard.tsx` — calls generate-tasks after non-draft save + +**Data impact:** BEK and Noor still need to have their setup wizard re-saved (or re-completed) to trigger task generation for existing groups, since the fix only fires on new completions going forward. Alternative: generate tasks manually via the existing group detail page. + +--- + +### 4. Manual Sync Hardcoded Date Range (Medium — Bug Fix) +**Symptom:** The Manual Sync panel defaulted to `2026-01-01` – `2026-12-31`, silently skipping policies outside that window. + +**Fix:** Defaults are now dynamic: start = January 1 of the prior year, end = December 31 two years out. This auto-adjusts every calendar year. + +**Files changed:** +- `src/components/admin/sync-trigger.tsx` + +**Data impact:** None. + +--- + +### 5. Setup Queue Showing Inactive Clients (Medium — Bug Fix) +**Symptom:** 26 clients were showing in the setup queue, including many with only cancelled/expired/non-renewed policies that don't need setup. + +**Fix:** The queue now filters to clients with at least one active (non-dead) policy. A note in the subtitle shows how many clients were excluded so managers have full visibility. + +Dead statuses filtered: `Cancelled`, `Expired`, `Non-Renewed`, `Rewritten`, `Not taken`. + +**Files changed:** +- `src/app/(dashboard)/manager/setup/page.tsx` + +**Data impact:** None. Queue count will be lower — that's correct. + +--- + +### 6. Audit Logging Gap — Notes Route (Medium — Bug Fix) +**Symptom:** When a user added a note and simultaneously changed task status, the audit log recorded `TASK_NOTE_ADDED` with only the new status. The old status was never captured, making it impossible to know what a task was before the change. + +**Fix:** When a status change accompanies a note, the route now also emits a dedicated `TASK_STATUS_CHANGED` audit entry with `oldValues.status` and `newValues.status`. The existing `TASK_NOTE_ADDED` entry was also updated to include `oldValues.status`. + +**Files changed:** +- `src/app/api/tasks/[id]/notes/route.ts` + +**Data impact:** No historical backfill. New entries going forward will be complete. Verification query: +```sql +SELECT action, old_values->>'status' AS old, new_values->>'status' AS new, created_at +FROM audit_logs WHERE action = 'TASK_STATUS_CHANGED' +ORDER BY created_at DESC LIMIT 10; +``` + +--- + +### 7. Renewal Warnings Not Showing on Policy Cards (Low — Enhancement) +**Symptom:** Day-count warning badges (e.g. "26d", "34d") appeared on client-level cards and lists but not on individual policy cards inside the client detail view. + +**Fix:** Each policy card in the client detail tab now shows: +- Red badge `Xd` if renewal is within 30 days +- Grey badge `Xd` if renewal is within 90 days + +**Files changed:** +- `src/components/clients/client-detail.tsx` + +--- + +### 8. Client Notes — No Save Attribution (Low — Enhancement) +**Symptom:** The Client Notes section had no indication of when notes were last saved or by whom. + +**Fix:** After saving notes, a "Saved [timestamp]" label appears next to the Save button for the duration of the session. + +**Note:** A persistent "last saved by" tracker would require a DB migration (adding `notesUpdatedAt` / `notesUpdatedBy` to the `clients` table). This was not added. If persistent tracking is desired, a migration can be written. + +**Files changed:** +- `src/components/clients/client-detail.tsx` + +--- + +## Verification — User Task Counts (Post-Deploy) + +Queried immediately after deploy. No tasks were modified by the code changes. + +| User | NOT_STARTED | IN_PROGRESS | COMPLETED | NA | +|---|---|---|---|---| +| Luke Billman | 236 | 7 | 164 | 43 | +| Mimi Rawlings | 516 | 5 | 602 | 38 | +| Christine Gove | 1,722 | — | 2,087 | 96 | + +All status changes since May 21 were made by the users themselves (no system reversions). + +--- + +## Deployment + +No database migrations were required. All changes are application-layer only. + +``` +git commit: 20f7ec1 +git push: main → forgejo.wulfconsulting.cloud/lorentz/seubert-claims +docker: horizon-app Recreated → Started (Up as of ~11:50 PM UTC May 28) +``` + +--- + +## Still Pending + +- **SHAPE import audit logging** — The import directly creates/updates tasks with `COMPLETED`/`NA` status but emits no per-task audit entries. Low risk (admin-only, rare operation). Plan documented in `dev/audit-logging-fix-plan.md`. +- **BEK/Noor task generation** — Needs manual trigger via group detail page, or re-completing setup, since they were set up before this fix. +- **Client notes persistent attribution** — Requires a DB migration to add `notesUpdatedAt` / `notesUpdatedBy` columns. diff --git a/dev/overdue-filter-bug-report-2026-06-01.md b/dev/overdue-filter-bug-report-2026-06-01.md new file mode 100644 index 0000000..dc6099c --- /dev/null +++ b/dev/overdue-filter-bug-report-2026-06-01.md @@ -0,0 +1,97 @@ +# Overdue Task Count Discrepancy +**Bug Report — June 1, 2026** + +--- + +## What's Happening + +The app has two different sets of rules for deciding which tasks are "visible" — and only one of them is correct. + +| Where | Rule Applied | Correct? | +|---|---|---| +| My Tasks page (direct load) | Show all open tasks, regardless of due date age | ✅ Yes | +| Manager "view as user" switcher | Hide open tasks whose due date is > 7 days old | ❌ No | +| Team Management dashboard | No visibility filters at all | ❌ No | +| Workload KPI dashboard | No visibility filters at all | ❌ No | +| Bulk Assign / Manage Tasks page | Hide open tasks whose due date is > 7 days old | ❌ No | +| Prometheus monitoring metrics | No visibility filters at all | ❌ No (low impact) | + +The correct rule is: **if a task is still open, always show it — no matter how old its due date is.** Only completed/closed tasks should age out of view. + +--- + +## Reported Symptoms Explained + +### Symptom 1 — "My Tasks shows 45 overdue, drops to 8 after switching users" + +When the page first loads, it uses the correct rules — 45 overdue tasks shown accurately. When a manager uses the **"Viewing as"** dropdown to switch to another user and then returns to themselves, the page now fetches tasks through the API instead of the server — which uses the broken rules. Tasks overdue by more than 7 days silently disappear, leaving only 8. + +> **Root cause:** `/api/tasks` (used by the user-switcher) was never updated to match the fix applied to the My Tasks page in May. + +--- + +### Symptom 2 — "Jeanne shows 131 overdue in her advocate view, but only 21 when logged in as her" + +The Team Management page and Workload KPI panels count overdue tasks with **no filtering at all** — they include tasks on cancelled/expired policies, tasks in old renewal groups, and tasks that the user's own page would correctly hide. Jeanne's actual My Tasks page applies the right filters and shows 21. + +> **Root cause:** The manager dashboard and workload API were never given the same visibility rules as the My Tasks page. + +--- + +### Symptom 3 — "Chris Gove shows 0 tasks in all timeframes (past due, this week, next week)" + +Christine has **1,722 open tasks** in the database. All of them are overdue by more than 7 days. When a manager views her tasks through the "Viewing as" switcher, the broken API filter hides every single one. All three buckets — overdue, this week, next week — show zero. + +Her tasks exist. They are simply invisible due to the filter bug. + +> **Root cause:** Same as Symptom 1 — the API's broken filter applied to Christine's tasks. + +--- + +## Impact After the Fix + +| User | Before Fix | After Fix | +|---|---|---| +| Any user (own tasks, direct load) | ✅ Correct — no change | ✅ No change | +| Manager viewing another user | ❌ Hidden tasks | ✅ Accurate count | +| Christine Gove | 0 visible tasks (manager view) | All 1,722 open tasks visible | +| Jeanne Strong | 131 shown in dashboard | Accurate, lower number | +| Team-wide overdue dashboard | Inflated by dead-policy tasks | Accurate, lower number | + +> **No data will change.** Only what is displayed changes. No tasks will be created, deleted, or modified. Christine's tasks surfacing as overdue reflects real work that needs attention — it is not a new problem created by the fix. + +--- + +## Fixes Required + +Four code changes are needed, all in existing API files. No database changes, no migrations, zero downtime. + +### Fix 1 — Task API filter (High Priority) +**File:** `src/app/api/tasks/route.ts` + +The "view as user" switcher, the Bulk Assign page, and the Manage Tasks page all use this API. Replacing its two broken filter conditions with the correct logic fixes Symptoms 1 and 3 immediately. + +### Fix 2 — Workload dashboard (High Priority) +**File:** `src/app/api/dashboard/workload/route.ts` + +Adds dead-policy and expired-group exclusions to the overdue counts shown in the manager dashboard and per-user workload rows. Fixes Symptom 2. + +### Fix 3 — Team Management page (Medium Priority) +**File:** `src/app/(dashboard)/manager/page.tsx` + +Adds the same visibility filters to the per-user active/overdue counts shown on the Team Management page. + +### Fix 4 — Prometheus metrics (Low Priority) +**File:** `src/app/api/metrics/route.ts` + +Corrects the `horizon_tasks_overdue` metric shown in monitoring. Does not affect anything users see in the application. + +--- + +## Deployment Plan + +1. Apply all four code fixes +2. TypeScript check: `npx tsc --noEmit` +3. Commit: `git commit -m "Fix: align task visibility filters across all API routes and dashboards"` +4. Deploy: `docker compose -f /opt/stacks/horizon/docker-compose.yml up -d --build` +5. Verify with spot-check SQL queries on Luke, Mimi, Chris, and Jeanne diff --git a/dev/shared-count-helper-plan-2026-06-02.md b/dev/shared-count-helper-plan-2026-06-02.md new file mode 100644 index 0000000..5dbb0c1 --- /dev/null +++ b/dev/shared-count-helper-plan-2026-06-02.md @@ -0,0 +1,118 @@ +# Plan — Shared Task-Count Engine (single source of truth) + +**Author:** Claude · **Date:** 2026-06-02 · **Status:** Draft for review +**Deploy window:** after hours, this week (TBD date) + +--- + +## 1. Why + +The overnight fix (`b1b0dfa`) resolved the headline bugs — Christine Gove now shows **258** overdue (was 0), Jeanne Strong **133** (was 21), and the "drops to 8 when switching users" glitch is gone. Verified live 2026-06-02. + +But the numbers still don't reconcile across screens, because **each surface computes "overdue / this week / next week" with its own formula.** Verified live today: + +| Symptom (live) | Numbers | Cause | +|---|---|---| +| Same person, different per screen | Christine 257 (team page) vs 258 (task view); Jeanne 131 vs 133 | "overdue" boundary = midnight on one screen, current moment on the other | +| Overlapping buckets | "1 Week Out" (days 8–14) ⊂ "2 Weeks Out" (days 8–21) | bucket windows overlap; same tasks counted in both | +| Dashboard self-contradiction | top "Overdue" = 519, sum of per-person overdue = 493 (Δ26) | aggregate counts a different population than the per-member rows | +| One person > whole team | Christine "This Week" = 90 > team "Due This Week" = 81 | different week definition + scope (all-clients vs Shape-only) | + +There are currently **6 independent implementations** of these counts: + +| # | File | Scope | "overdue" boundary | "now" basis | +|---|---|---|---|---| +| 1 | `app/(dashboard)/tasks/page.tsx` (SSR initial) | all clients | n/a (returns rows) | — | +| 2 | `app/(dashboard)/tasks/page-client.tsx:709-732` (cards) | loaded rows (cap 1000) | `dueDate < now` (current time) | **browser** | +| 3 | `app/api/tasks/route.ts` (switcher/bulk) | all clients | n/a (returns rows) | — | +| 4 | `app/api/dashboard/workload/route.ts:93-121` | **Shape only** | `dueDate < today` (midnight) | **server** | +| 5 | `app/(dashboard)/manager/page.tsx` + `components/manager/team-members-by-department.tsx:108` | **Shape only** | `dueDate < today` (midnight) | server-render, browser-eval | +| 6 | `app/api/metrics/route.ts:26-41` | all clients | `dueDate < todayStart` | server | + +## 2. Goal + +One shared module that owns **(a)** the task-visibility filter, **(b)** the "now"/week-boundary math, and **(c)** the bucket definitions. Every surface calls it. Numbers reconcile by construction. + +--- + +## 3. Decisions to lock before coding + +These are product decisions, not code details. Recommended defaults in **bold**; please confirm or override. + +1. **"Overdue" boundary** — count a task overdue when `dueDate < start of today`. **Recommend: start-of-day** (a task due *today* is "due today," not "overdue"). Apply identically everywhere. Resolves the 257-vs-258 and 131-vs-133 drift. +2. **Time zone** — all day/week math computed in **`America/New_York`** (Seubert's business zone), server-side, never browser-local. Resolves off-by-a-day at midnight and the browser-vs-server split. (Today's cards use the viewer's browser clock — a user in a different zone sees different buckets.) +3. **Bucket definitions** — non-overlapping, contiguous: + - **Overdue** = `dueDate < today` + - **This week** = `today ≤ dueDate ≤ today+6` (days 1–7) + - **Next week** = `today+7 ≤ dueDate ≤ today+13` (days 8–14) + - (drop or relabel the current "2 Weeks Out = days 8–21" card, which overlaps) + - All exclude terminal statuses (COMPLETED/CANCELLED/NA) and dead-policy tasks; open tasks are never aged out. +4. **Scope on manager/dashboard surfaces** — *decision needed.* Today the team dashboard and team page count **Shape clients only**, while a user's own task list counts **all clients**. For users with non-Shape work these will never match. **Recommend: make the per-user task view also Shape-scoped** so "what the manager sees" == "what the employee sees," OR add an explicit "Shape only" toggle. Flagging for your call — this is the one behavior change end-users might notice. +5. **Aggregate vs sum** — the dashboard "Overdue" total should equal the sum of the per-person rows shown beneath it. **Recommend:** define the aggregate as "overdue tasks assigned to listed active team members," so top number == sum of rows (no orphan/unassigned tasks silently inflating the header). +6. **Counts come from the server, not the browser** — cards must request counts from an endpoint, not compute them from a capped 1,000-row array. Fixes the latent truncation bug (Christine has 1,719 open tasks; "this week"/"next week" can silently undercount once a user exceeds 1,000). + +--- + +## 4. Implementation + +### 4.1 New shared module — `src/lib/task-buckets.ts` +- `businessNow()` / `startOfBusinessDay()` — fixed `America/New_York` "today" and week edges. +- `taskVisibilityWhere(opts)` — the canonical Prisma `where` (dead-policy exclusion, expired-group rule that never hides open tasks, terminal-recency rule). Single definition; surfaces 1/3 import it instead of inlining. +- `bucketWhere(bucket, base)` — returns the `where` for `overdue | thisWeek | nextWeek | dueToday`, built from the agreed boundaries. +- `BUCKETS` — ordered, non-overlapping definitions + labels, consumed by the cards. +- Unit tests (Vitest/Jest) covering boundary cases: due exactly at midnight, DST transitions, terminal-but-recent, open-but-old, dead policy, expired group. + +### 4.2 New counts endpoint — `src/app/api/tasks/counts/route.ts` +- `GET ?userId=…&scope=shape|all` → `{ overdue, thisWeek, nextWeek }` computed server-side via `bucketWhere`. Authz mirrors `/api/tasks` (Admin/Manager may pass `userId`). +- Cards call this; they stop deriving counts from the loaded task array. + +### 4.3 Refactor each surface to call the shared code +| File | Change | +|---|---| +| `tasks/page-client.tsx` | Replace `now`/`week*End` consts and the `overdue/dueToday/due1Week/due2Weeks` filters (lines 709–732) with values from the counts endpoint; remove the overlapping "2 Weeks Out" card or relabel per decision #3. | +| `api/tasks/route.ts` | Import `taskVisibilityWhere` instead of the inline AND-block (lines 31–60). | +| `tasks/page.tsx` | Import `taskVisibilityWhere` (lines 23–53). Keep `take` but raise/paginate, or rely on counts endpoint for totals. | +| `api/dashboard/workload/route.ts` | Use `bucketWhere`/boundaries; align scope + aggregate-vs-sum per decisions #4/#5 (lines 93–177). | +| `manager/page.tsx` + `team-members-by-department.tsx` | Use shared boundaries/`startOfBusinessDay` instead of local `today` (line 108). | +| `api/metrics/route.ts` | Use shared overdue definition (lines 26–41). | + +No DB schema change. No migration. No data is modified — only what's displayed. + +--- + +## 5. Verification (before & after, gated by evidence) + +**A. SQL ground truth** (read-only, against `horizon-db`) — compute overdue/thisWeek/nextWeek per the agreed definitions for a fixed sample: Christine Gove, Jeanne Strong, Mimi Rawlings, Luke Billman, + one all-clients user. Record expected numbers. + +**B. Reconciliation assertions** — must all hold post-deploy: +1. Manager team-page overdue for user X **==** that user's task-view Overdue card **==** SQL truth. +2. Dashboard top "Overdue" **==** sum of per-member overdue rows. +3. No single user's bucket exceeds the team aggregate for that bucket. +4. "This week" and "next week" are disjoint; no task appears in both. +5. A user with >1,000 tasks (Christine) has correct thisWeek/nextWeek (truncation gone). + +**C. Live Playwright walkthrough** (as today): manager team page → switcher for each sample user → dashboard KPIs; capture screenshots; confirm B1–B5 on screen. + +**D. `npx tsc --noEmit`** clean; unit tests green. + +--- + +## 6. Rollout (after-hours, this week) + +1. Branch `fix/shared-task-counts`; implement §4; run §5-A/D on the branch. +2. Open PR; self-review + `/code-review`. +3. In the window: `docker compose -f /opt/stacks/horizon/docker-compose.yml up -d --build`. +4. Post-deploy: run §5-B/C; attach screenshots. +5. **Rollback:** revert the merge commit and rebuild (no schema/data change, so rollback is a clean redeploy of the prior image). + +**User-facing note for the window:** "Brief maintenance — task counts will be momentarily unavailable. No tasks change; only how totals are displayed is being made consistent across screens." + +--- + +## 7. Open items needing your sign-off +- Decision #1 (overdue = start of day) ✅ recommend +- Decision #2 (America/New_York) ✅ recommend +- Decision #3 (drop "2 Weeks Out" overlap) ✅ recommend +- **Decision #4 (Shape-only vs all-clients on user task view)** ⬅ needs your call — only item with visible behavior change +- Decision #5 (aggregate == sum of rows) ✅ recommend +- Decision #6 (server-side counts) ✅ recommend diff --git a/ondeck/Dockerfile b/ondeck/Dockerfile index c55e2be..fa3c487 100644 --- a/ondeck/Dockerfile +++ b/ondeck/Dockerfile @@ -42,6 +42,10 @@ COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma +# Next's standalone output file-tracer doesn't follow @napi-rs/canvas's platform-specific +# optional dependency (resolved dynamically at require() time) — copy it in full, same as +# .prisma above. Needed by pdf-parse (see src/lib/imageright/content-inspector.ts). +COPY --from=builder /app/node_modules/@napi-rs ./node_modules/@napi-rs # Set correct permissions RUN chown -R nextjs:nodejs /app diff --git a/ondeck/next.config.ts b/ondeck/next.config.ts index 48ad8c4..9c5bb6a 100644 --- a/ondeck/next.config.ts +++ b/ondeck/next.config.ts @@ -4,6 +4,9 @@ const nextConfig: NextConfig = { /* config options here */ reactCompiler: true, output: 'standalone', + // pdf-parse and its optional native canvas dependency must not be bundled by + // Turbopack/webpack — see src/lib/imageright/content-inspector.ts. + serverExternalPackages: ['pdf-parse', '@napi-rs/canvas'], }; export default nextConfig; diff --git a/ondeck/package-lock.json b/ondeck/package-lock.json index 9033ccb..b9d63e5 100644 --- a/ondeck/package-lock.json +++ b/ondeck/package-lock.json @@ -13,6 +13,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@kenjiuno/msgreader": "^1.28.0", + "@napi-rs/canvas": "^1.0.2", "@prisma/adapter-pg": "^7.2.0", "@prisma/client": "^7.2.0", "@radix-ui/react-avatar": "^1.1.11", @@ -2898,9 +2899,9 @@ } }, "node_modules/@napi-rs/canvas": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz", - "integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.2.tgz", + "integrity": "sha512-EYEqlMYaCbpZDz+IgDH5xp9MTd3ui4dmGqbQYryhMLnSRxrhHKq5KQWHHKxFUcEP4Hp8/BWgvqXocX4j7iSbOQ==", "license": "MIT", "workspaces": [ "e2e/*" @@ -2908,23 +2909,28 @@ "engines": { "node": ">= 10" }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, "optionalDependencies": { - "@napi-rs/canvas-android-arm64": "0.1.80", - "@napi-rs/canvas-darwin-arm64": "0.1.80", - "@napi-rs/canvas-darwin-x64": "0.1.80", - "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80", - "@napi-rs/canvas-linux-arm64-gnu": "0.1.80", - "@napi-rs/canvas-linux-arm64-musl": "0.1.80", - "@napi-rs/canvas-linux-riscv64-gnu": "0.1.80", - "@napi-rs/canvas-linux-x64-gnu": "0.1.80", - "@napi-rs/canvas-linux-x64-musl": "0.1.80", - "@napi-rs/canvas-win32-x64-msvc": "0.1.80" + "@napi-rs/canvas-android-arm64": "1.0.2", + "@napi-rs/canvas-darwin-arm64": "1.0.2", + "@napi-rs/canvas-darwin-x64": "1.0.2", + "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.2", + "@napi-rs/canvas-linux-arm64-gnu": "1.0.2", + "@napi-rs/canvas-linux-arm64-musl": "1.0.2", + "@napi-rs/canvas-linux-riscv64-gnu": "1.0.2", + "@napi-rs/canvas-linux-x64-gnu": "1.0.2", + "@napi-rs/canvas-linux-x64-musl": "1.0.2", + "@napi-rs/canvas-win32-arm64-msvc": "1.0.2", + "@napi-rs/canvas-win32-x64-msvc": "1.0.2" } }, "node_modules/@napi-rs/canvas-android-arm64": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz", - "integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.2.tgz", + "integrity": "sha512-IMXKVQod0ol4vt3gmClUfXz4JAgHYESGPCUqmH3lQxBoL0K/2greJaQE1HVBVxWWFKfLc4OLZVdxg7kXVyXv+g==", "cpu": [ "arm64" ], @@ -2935,12 +2941,16 @@ ], "engines": { "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" } }, "node_modules/@napi-rs/canvas-darwin-arm64": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz", - "integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-Sc8tPi6cF+5lqOzCCKFALJHhDiRwyMzTPYm3bbhdXsOunU0lQO5f05ucyOzN2r55I23Hg5bsjH63uSCvWp3EgQ==", "cpu": [ "arm64" ], @@ -2951,12 +2961,16 @@ ], "engines": { "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" } }, "node_modules/@napi-rs/canvas-darwin-x64": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz", - "integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.2.tgz", + "integrity": "sha512-niDXZ9LhKB1zLrUdYB64RHQFDGz9rr0eGx061qtJJU3U20EMMIx28ADF5fVYbhtOgkWQrBjFicfaye1yM0U62A==", "cpu": [ "x64" ], @@ -2967,12 +2981,16 @@ ], "engines": { "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" } }, "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz", - "integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-sgatQL9JxGRH/Amzcvu0P3t8Am3duou74CisfuJ41Dwt8cWy723z/9KZ8LlgmxfypEwEZxSTNFJtU8d281lmhQ==", "cpu": [ "arm" ], @@ -2983,12 +3001,16 @@ ], "engines": { "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" } }, "node_modules/@napi-rs/canvas-linux-arm64-gnu": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz", - "integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-dgKuX0peF3xwY6ZF5QxGS4wbfDqpoFAJYXiLSp+guZKARQUKMkRqZSDrXKj7nfrec3UCMzC0PFCPte0ES98AiA==", "cpu": [ "arm64" ], @@ -2999,12 +3021,16 @@ ], "engines": { "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" } }, "node_modules/@napi-rs/canvas-linux-arm64-musl": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz", - "integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-qwROoDIC9upfvDoRLuPn2aNg9CGW1x0Ygr4k2Or+8paA9d0qBLwk87U+g8KQpoOviKoPoiwl97kvBYuYD7qZoA==", "cpu": [ "arm64" ], @@ -3015,12 +3041,16 @@ ], "engines": { "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" } }, "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz", - "integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.2.tgz", + "integrity": "sha512-fXRjnPihdnbO6qy1QQOgxAonb68A0TCEG7rj1x7v7rxNElsE8EVIKIEUTvyDtU+sthYSbX+8e7g3oZiLGnOmxw==", "cpu": [ "riscv64" ], @@ -3031,12 +3061,16 @@ ], "engines": { "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" } }, "node_modules/@napi-rs/canvas-linux-x64-gnu": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz", - "integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-nPR97DXhbWIAy7yazF3jc06kEPMqYMLmPzFOVNlwKPfIoSChnI+x7dc0hTLaihz3jxrjL6j4BbA7earxfx4X3g==", "cpu": [ "x64" ], @@ -3047,12 +3081,16 @@ ], "engines": { "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" } }, "node_modules/@napi-rs/canvas-linux-x64-musl": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz", - "integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-l7zZY5+jL5qnBZtDz7CoBtY6p7EkHu422g/0zWwrOrzIwWyWxZFRfZZORY1UG7YApymPLx+UbOkN206xXn/c1Q==", "cpu": [ "x64" ], @@ -3063,12 +3101,36 @@ ], "engines": { "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-yE0koHCFF4PIbMc2o2SEALhnipz7WBISh5glLvQiomtIoCcW0np3H4Lw93ceJAfJttTTeIIWFbwH84F7EVzjMQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" } }, "node_modules/@napi-rs/canvas-win32-x64-msvc": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz", - "integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-okU8/t2foV6C31n0GtvEMbfD5rOFc70+/6xUNME9Guld29sgSOIGUEDScAWFlcP3k5TYQRl9TNkwJEEjh15w8A==", "cpu": [ "x64" ], @@ -3079,6 +3141,10 @@ ], "engines": { "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" } }, "node_modules/@napi-rs/wasm-runtime": { @@ -13967,6 +14033,190 @@ "url": "https://github.com/sponsors/mehmet-kozan" } }, + "node_modules/pdf-parse/node_modules/@napi-rs/canvas": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz", + "integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==", + "license": "MIT", + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.80", + "@napi-rs/canvas-darwin-arm64": "0.1.80", + "@napi-rs/canvas-darwin-x64": "0.1.80", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.80", + "@napi-rs/canvas-linux-arm64-musl": "0.1.80", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.80", + "@napi-rs/canvas-linux-x64-gnu": "0.1.80", + "@napi-rs/canvas-linux-x64-musl": "0.1.80", + "@napi-rs/canvas-win32-x64-msvc": "0.1.80" + } + }, + "node_modules/pdf-parse/node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz", + "integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/pdf-parse/node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz", + "integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/pdf-parse/node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz", + "integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/pdf-parse/node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz", + "integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/pdf-parse/node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz", + "integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/pdf-parse/node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz", + "integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/pdf-parse/node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz", + "integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/pdf-parse/node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz", + "integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/pdf-parse/node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz", + "integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/pdf-parse/node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz", + "integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/pdfjs-dist": { "version": "5.4.296", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz", @@ -13979,6 +14229,256 @@ "@napi-rs/canvas": "^0.1.80" } }, + "node_modules/pdfjs-dist/node_modules/@napi-rs/canvas": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz", + "integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.100", + "@napi-rs/canvas-darwin-arm64": "0.1.100", + "@napi-rs/canvas-darwin-x64": "0.1.100", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.100", + "@napi-rs/canvas-linux-arm64-musl": "0.1.100", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.100", + "@napi-rs/canvas-linux-x64-gnu": "0.1.100", + "@napi-rs/canvas-linux-x64-musl": "0.1.100", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.100", + "@napi-rs/canvas-win32-x64-msvc": "0.1.100" + } + }, + "node_modules/pdfjs-dist/node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz", + "integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/pdfjs-dist/node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz", + "integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/pdfjs-dist/node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz", + "integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/pdfjs-dist/node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz", + "integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/pdfjs-dist/node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz", + "integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/pdfjs-dist/node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz", + "integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/pdfjs-dist/node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz", + "integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/pdfjs-dist/node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz", + "integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/pdfjs-dist/node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz", + "integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/pdfjs-dist/node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz", + "integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/pdfjs-dist/node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz", + "integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", diff --git a/ondeck/package.json b/ondeck/package.json index 466a43d..a9377d2 100644 --- a/ondeck/package.json +++ b/ondeck/package.json @@ -23,6 +23,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@kenjiuno/msgreader": "^1.28.0", + "@napi-rs/canvas": "^1.0.2", "@prisma/adapter-pg": "^7.2.0", "@prisma/client": "^7.2.0", "@radix-ui/react-avatar": "^1.1.11", diff --git a/ondeck/src/app/api/clients/[id]/task-audit/route.ts b/ondeck/src/app/api/clients/[id]/task-audit/route.ts index a5baad5..fadc588 100644 --- a/ondeck/src/app/api/clients/[id]/task-audit/route.ts +++ b/ondeck/src/app/api/clients/[id]/task-audit/route.ts @@ -40,10 +40,14 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ include: { runByUser: { select: { displayName: true, email: true } } }, }) + // Dedup by task, not specItemKey: runTaskAudit now produces one result per real open + // Task row, so multiple simultaneously-open tasks can legitimately share a specItemKey + // (e.g. duplicate "Request 125 day loss runs" tasks from multiple linked policies). const latestByKey = new Map() for (const audit of audits) { - if (!latestByKey.has(audit.specItemKey)) { - latestByKey.set(audit.specItemKey, audit) + const key = audit.taskId ?? `${audit.specItemKey}:${audit.targetDate.toISOString()}` + if (!latestByKey.has(key)) { + latestByKey.set(key, audit) } } diff --git a/ondeck/src/components/clients/task-audit-panel.tsx b/ondeck/src/components/clients/task-audit-panel.tsx index cfd96d4..457a676 100644 --- a/ondeck/src/components/clients/task-audit-panel.tsx +++ b/ondeck/src/components/clients/task-audit-panel.tsx @@ -66,7 +66,7 @@ const SPEC_TITLES: Record = { review_reserves: 'Review reserves and negotiate adjustments where applicable', claim_review_180: 'Claim Review (180 days)', project_exp_mod_factor: 'Project experience modification factor; send to Account Executive', - request_120_day_loss_runs: 'Request 120 day loss runs', + request_125_day_loss_runs: 'Request 125 day loss runs', captive_claims_worksheet: 'Assist with captive claims worksheet (if applicable)', loss_summary_pre_renewal: 'Prepare loss summary/analysis for internal pre-renewal meeting', request_90_day_loss_runs: 'Request 90 day loss runs', @@ -78,6 +78,7 @@ export function TaskAuditPanel({ clientId }: { clientId: string }) { const [loading, setLoading] = useState(true) const [running, setRunning] = useState(false) const [fileNotFound, setFileNotFound] = useState(false) + const [hasRun, setHasRun] = useState(false) const [error, setError] = useState(null) const loadResults = async () => { @@ -87,6 +88,7 @@ export function TaskAuditPanel({ clientId }: { clientId: string }) { if (res.ok) { const data = await res.json() setItems(data.items ?? []) + setHasRun((data.items ?? []).length > 0) setFileNotFound( data.items?.length > 0 && data.items.every((i: TaskAuditItem) => i.errorMessage === 'No ImageRight file found for this client') ) @@ -113,8 +115,9 @@ export function TaskAuditPanel({ clientId }: { clientId: string }) { throw new Error(data.error || 'Audit run failed') } setItems(data.items.map((i: any) => ({ ...i, targetDate: i.targetDate }))) + setHasRun(true) setFileNotFound(!data.fileFound) - toast.success('ImageRight audit complete') + toast.success(data.items.length === 0 ? 'No open/overdue SHAPE tasks to audit' : 'ImageRight audit complete') } catch (err: any) { setError(err.message) toast.error(err.message || 'Audit run failed') @@ -150,13 +153,15 @@ export function TaskAuditPanel({ clientId }: { clientId: string }) { ) : items.length === 0 ? (
- No audit has been run yet. Click "Run Audit" to check ImageRight against the SHAPE checklist. + {hasRun + ? 'No open or overdue SHAPE tasks for this client — nothing to audit right now.' + : 'No audit has been run yet. Click "Run Audit" to check ImageRight against this client\'s open/overdue SHAPE tasks.'}
) : (
{items.map((item) => (
diff --git a/ondeck/src/lib/imageright/__tests__/audit-decision-logic.test.ts b/ondeck/src/lib/imageright/__tests__/audit-decision-logic.test.ts index c16f687..b8d92e1 100644 --- a/ondeck/src/lib/imageright/__tests__/audit-decision-logic.test.ts +++ b/ondeck/src/lib/imageright/__tests__/audit-decision-logic.test.ts @@ -178,11 +178,11 @@ describe('auditSpecItem — fail missing', () => { expect(item.classification).toBe('FAIL_MISSING') }) - it('classifies NOT_APPLICABLE instead, for conditional items with no candidate', async () => { + it('classifies FAIL_MISSING (not NOT_APPLICABLE) for a conditional item with no candidate — runTaskAudit only ever calls this for a real open task, whose existence already establishes applicability', async () => { const captiveSpec = SHAPE_AUDIT_SPEC.find((s) => s.key === 'captive_claims_worksheet')! const client = makeFakeClient({ findDocuments: jest.fn().mockResolvedValue([]) }) const { item } = await auditSpecItem(client, files as any, captiveSpec, targetDate, renewalYear) - expect(item.classification).toBe('NOT_APPLICABLE') + expect(item.classification).toBe('FAIL_MISSING') }) }) diff --git a/ondeck/src/lib/imageright/__tests__/audit-engine.test.ts b/ondeck/src/lib/imageright/__tests__/audit-engine.test.ts index 43a191a..8c8cdda 100644 --- a/ondeck/src/lib/imageright/__tests__/audit-engine.test.ts +++ b/ondeck/src/lib/imageright/__tests__/audit-engine.test.ts @@ -4,6 +4,7 @@ import { matchesDocType, normalizeForMatch, docTypesToExtensions, + matchTaskToSpec, findPolicyTermFolder, findChildFolder, type ImageRightFolder, @@ -93,6 +94,35 @@ describe('docTypesToExtensions', () => { }) }) +describe('matchTaskToSpec', () => { + it('strips a trailing conditional qualifier before matching', () => { + const match = matchTaskToSpec( + 'SHAPE Onboarding Checklist (required after first renewal or when changing carriers)', + SHAPE_AUDIT_SPEC + ) + expect(match?.key).toBe('shape_onboarding_checklist') + }) + + it('matches "Claims Review" (plural) to a "Claim Review" spec item', () => { + const match = matchTaskToSpec('Claims Review', SHAPE_AUDIT_SPEC) + expect(match?.task).toBe('Claim Review') + }) + + it('matches the live TaskTemplate name "Request 125 day loss runs" exactly', () => { + const match = matchTaskToSpec('Request 125 day loss runs', SHAPE_AUDIT_SPEC) + expect(match?.key).toBe('request_125_day_loss_runs') + }) + + it('falls back to a prefix match for an older/renamed template', () => { + const match = matchTaskToSpec('Claim Review in conjunction with 120 day loss summary. Confirm if being marketed.', SHAPE_AUDIT_SPEC) + expect(match?.task).toBe('Claim Review') + }) + + it('returns undefined for a title with no plausible spec match', () => { + expect(matchTaskToSpec('Completely unrelated ad-hoc task', SHAPE_AUDIT_SPEC)).toBeUndefined() + }) +}) + describe('matchesDocType', () => { it('matches when actual type is contained in an expected type', () => { expect(matchesDocType('EMAIL', ['EMAIL'])).toBe(true) diff --git a/ondeck/src/lib/imageright/__tests__/run-task-audit.test.ts b/ondeck/src/lib/imageright/__tests__/run-task-audit.test.ts new file mode 100644 index 0000000..7a99e59 --- /dev/null +++ b/ondeck/src/lib/imageright/__tests__/run-task-audit.test.ts @@ -0,0 +1,163 @@ +const mockClientFindUnique = jest.fn() +const mockTaskFindMany = jest.fn() +const mockTaskAuditCreateMany = jest.fn().mockResolvedValue({ count: 0 }) + +jest.mock('@/lib/db', () => ({ + prisma: { + client: { findUnique: (...args: any[]) => mockClientFindUnique(...args) }, + task: { findMany: (...args: any[]) => mockTaskFindMany(...args) }, + taskAudit: { createMany: (...args: any[]) => mockTaskAuditCreateMany(...args) }, + }, +})) + +const mockAuthenticate = jest.fn().mockResolvedValue(undefined) +const mockFindFilesByFileNumber = jest.fn().mockResolvedValue([{ id: 10820357 }]) +const mockGetSortedFolders = jest.fn().mockResolvedValue([]) +const mockFindDocuments = jest.fn().mockResolvedValue([]) + +jest.mock('../client', () => ({ + ImageRightClient: { + fromEnv: () => ({ + authenticate: mockAuthenticate, + findFilesByFileNumber: mockFindFilesByFileNumber, + getSortedFolders: mockGetSortedFolders, + findDocuments: mockFindDocuments, + getDocumentPages: jest.fn().mockResolvedValue([]), + getPageImageContent: jest.fn().mockResolvedValue(Buffer.from('')), + }), + }, + ImageRightConfigError: class ImageRightConfigError extends Error {}, +})) + +jest.mock('../content-inspector', () => ({ + inspectContent: jest.fn(), +})) + +import { runTaskAudit } from '../audit-engine' + +const DAY_MS = 24 * 60 * 60 * 1000 +const now = new Date('2026-07-15T00:00:00Z') + +beforeEach(() => { + jest.clearAllMocks() + jest.useFakeTimers().setSystemTime(now) + mockTaskAuditCreateMany.mockResolvedValue({ count: 0 }) + mockAuthenticate.mockResolvedValue(undefined) + mockFindFilesByFileNumber.mockResolvedValue([{ id: 10820357 }]) + mockGetSortedFolders.mockResolvedValue([]) + mockFindDocuments.mockResolvedValue([]) + mockClientFindUnique.mockResolvedValue({ id: 'client-1', amsCustomerNumber: 7620 }) +}) + +afterEach(() => { + jest.useRealTimers() +}) + +describe('runTaskAudit', () => { + it('throws when the client has no AMS customer number', async () => { + mockClientFindUnique.mockResolvedValue({ id: 'client-1', amsCustomerNumber: null }) + await expect(runTaskAudit('client-1')).rejects.toThrow(/AMS customer number/) + }) + + it('queries only open tasks due now or overdue (excludes future, completed, NA, cancelled)', async () => { + mockTaskFindMany.mockResolvedValue([]) + await runTaskAudit('client-1') + expect(mockTaskFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + clientId: 'client-1', + dueDate: { lte: now }, + status: { notIn: ['COMPLETED', 'NA', 'CANCELLED'] }, + }), + }) + ) + }) + + it('uses each open task\'s own dueDate as targetDate — not a value recomputed from Client.renewalDate', async () => { + const dueDate = new Date('2026-07-09T00:00:00Z') + mockTaskFindMany.mockResolvedValue([ + { id: 'task-1', title: 'Claim Review', dueDate, daysOffset: -90 }, + ]) + + const result = await runTaskAudit('client-1') + + expect(result.items).toHaveLength(1) + expect(result.items[0].targetDate).toEqual(dueDate) + expect(result.items[0].linkedTaskId).toBe('task-1') + expect(result.items[0].specItemKey).toBe('claim_review_90') + }) + + it('derives the ImageRight Policy Term folder year from the task\'s own (negative) daysOffset, not dueDate.getFullYear()', async () => { + // Due late one calendar year, prepping for a renewal early the next — dueDate.getFullYear() + // alone would pick the wrong Policy Term folder. + const dueDate = new Date('2026-12-20T00:00:00Z') + const daysOffset = -45 // implied renewal: 2026-12-20 + 45 days = 2027-02-03 + mockTaskFindMany.mockResolvedValue([{ id: 'task-1', title: 'Claims Review', dueDate, daysOffset }]) + mockGetSortedFolders.mockResolvedValue([]) + + await runTaskAudit('client-1') + + // findPolicyTermFolder is called indirectly via resolveParentId -> getSortedFolders; + // the folderChecked label on the FAIL_MISSING result surfaces the year it looked for. + const impliedYear = new Date(dueDate.getTime() - daysOffset * DAY_MS).getFullYear() + expect(impliedYear).toBe(2027) + }) + + it('matches "Claims Review" (plural) and strips a trailing conditional qualifier before matching', async () => { + mockTaskFindMany.mockResolvedValue([ + { id: 'task-1', title: 'Claims Review', dueDate: new Date('2026-07-09'), daysOffset: -90 }, + { + id: 'task-2', + title: 'SHAPE Onboarding Checklist (required after first renewal or when changing carriers)', + dueDate: new Date('2026-01-01'), + daysOffset: -335, + }, + ]) + + const result = await runTaskAudit('client-1') + + expect(result.items.find((i) => i.linkedTaskId === 'task-1')?.specItemKey).toBe('claim_review_90') + expect(result.items.find((i) => i.linkedTaskId === 'task-2')?.specItemKey).toBe('shape_onboarding_checklist') + }) + + it('flags a task with no matching SHAPE spec item as MANUAL_REVIEW instead of silently dropping it', async () => { + mockTaskFindMany.mockResolvedValue([ + { id: 'task-1', title: 'Completely unrelated ad-hoc task', dueDate: new Date('2026-07-09'), daysOffset: 0 }, + ]) + + const result = await runTaskAudit('client-1') + + expect(result.items).toHaveLength(1) + expect(result.items[0].classification).toBe('MANUAL_REVIEW') + expect(result.items[0].specItemKey).toBe('unmatched:task-1') + expect(result.items[0].linkedTaskId).toBe('task-1') + expect(result.items[0].errorMessage).toContain('No SHAPE audit spec item matches') + }) + + it('persists one taskAudit row per open task, linked directly via taskId (no fuzzy re-matching)', async () => { + mockTaskFindMany.mockResolvedValue([ + { id: 'task-1', title: 'Claim Review', dueDate: new Date('2026-07-09'), daysOffset: -90 }, + ]) + + await runTaskAudit('client-1', 'user-1') + + expect(mockTaskAuditCreateMany).toHaveBeenCalledWith( + expect.objectContaining({ + data: [expect.objectContaining({ clientId: 'client-1', taskId: 'task-1', runBy: 'user-1' })], + }) + ) + }) + + it('returns FAIL_MISSING for a matched spec item when no ImageRight file exists for the client', async () => { + mockFindFilesByFileNumber.mockResolvedValue([]) + mockTaskFindMany.mockResolvedValue([ + { id: 'task-1', title: 'Claim Review', dueDate: new Date('2026-07-09'), daysOffset: -90 }, + ]) + + const result = await runTaskAudit('client-1') + + expect(result.fileFound).toBe(false) + expect(result.items[0].classification).toBe('FAIL_MISSING') + expect(result.items[0].errorMessage).toContain('No ImageRight file found') + }) +}) diff --git a/ondeck/src/lib/imageright/audit-engine.ts b/ondeck/src/lib/imageright/audit-engine.ts index 09d8094..9320ed9 100644 --- a/ondeck/src/lib/imageright/audit-engine.ts +++ b/ondeck/src/lib/imageright/audit-engine.ts @@ -5,6 +5,7 @@ import { computeWindow, matchesKeywords, matchesDocType, + matchTaskToSpec, findPolicyTermFolder, findChildFolder, type ImageRightFolder, @@ -260,7 +261,6 @@ export async function auditSpecItem( renewalYear: number ): Promise { const { start, end } = computeWindow(spec, targetDate) - const isConditional = !!spec.condition?.toLowerCase().match(/applicable|conditional/) let folderChecked = spec.folderPath.join(' > ') || 'ALL_TIME (whole file)' let bestInWindow: ScoredCandidate | null = null @@ -292,13 +292,14 @@ export async function auditSpecItem( const base = { specItemKey: spec.key, task: spec.task, targetDate, folderChecked } if (!bestAnyTime) { + // No NOT_APPLICABLE short-circuit here: runTaskAudit only ever calls this for a + // client's real, already-generated open Task — its existence already establishes + // applicability (conditional items like "captive claims worksheet" simply won't + // have an open task if they don't apply), so a missing filing is a genuine gap. return { item: { ...base, - classification: isConditional ? 'NOT_APPLICABLE' : 'FAIL_MISSING', - errorMessage: isConditional - ? 'No matching document found; item is conditional — confirm applicability manually' - : undefined, + classification: 'FAIL_MISSING', }, } } @@ -420,33 +421,8 @@ export async function auditSpecItem( } } -/** Best-effort link to an existing Task: same client, title match, closest dueDate to targetDate. */ -async function findLinkedTask(clientId: string, taskTitle: string, targetDate: Date): Promise { - const candidates = await prisma.task.findMany({ - where: { clientId, title: taskTitle }, - select: { id: true, dueDate: true }, - }) - if (candidates.length === 0) return undefined - candidates.sort( - (a, b) => Math.abs(a.dueDate.getTime() - targetDate.getTime()) - Math.abs(b.dueDate.getTime() - targetDate.getTime()) - ) - return candidates[0].id -} - -async function resolveEffectiveDate(clientId: string): Promise { - const client = await prisma.client.findUnique({ - where: { id: clientId }, - select: { renewalDate: true }, - }) - if (client?.renewalDate) return client.renewalDate - - const earliestGroup = await prisma.policyGroup.findFirst({ - where: { clientId }, - orderBy: { renewalDate: 'asc' }, - select: { renewalDate: true }, - }) - return earliestGroup?.renewalDate ?? null -} +/** Task statuses that mean a task is closed out and no longer needs auditing — mirrors the definition used by /api/metrics' overdueTasks gauge. */ +const CLOSED_TASK_STATUSES = ['COMPLETED', 'NA', 'CANCELLED'] as const export async function runTaskAudit(clientId: string, runByUserId?: string): Promise { const client = await prisma.client.findUnique({ @@ -460,50 +436,74 @@ export async function runTaskAudit(clientId: string, runByUserId?: string): Prom ) } - const effectiveDate = await resolveEffectiveDate(clientId) - if (!effectiveDate) { - throw new Error('Client has no renewal date configured — cannot compute audit target dates') - } + // Audit the client's real, already-generated open tasks (overdue or due now) rather + // than recomputing target dates from Client.renewalDate — see shape-audit-spec.ts's + // doc comment on `daysAfterRenewal` for why that would silently compute dates a full + // renewal cycle off from reality. + const openTasks = await prisma.task.findMany({ + where: { + clientId, + dueDate: { lte: new Date() }, + status: { notIn: [...CLOSED_TASK_STATUSES] }, + }, + select: { id: true, title: true, dueDate: true, daysOffset: true }, + orderBy: { dueDate: 'asc' }, + }) const irClient = ImageRightClient.fromEnv() await irClient.authenticate() const files = await irClient.findFilesByFileNumber(String(client.amsCustomerNumber)) const fileFound = files.length > 0 - const renewalYear = effectiveDate.getFullYear() const items: TaskAuditItemResult[] = [] - for (const spec of SHAPE_AUDIT_SPEC) { - const targetDate = new Date(effectiveDate.getTime() + spec.daysAfterRenewal * DAY_MS) + for (const task of openTasks) { + const spec = matchTaskToSpec(task.title, SHAPE_AUDIT_SPEC) + const folderChecked = spec?.folderPath.join(' > ') || 'ALL_TIME (whole file)' let result: TaskAuditItemResult - try { - if (!fileFound) { - result = { - specItemKey: spec.key, - task: spec.task, - targetDate, - classification: 'FAIL_MISSING', - folderChecked: spec.folderPath.join(' > ') || 'ALL_TIME (whole file)', - errorMessage: 'No ImageRight file found for this client', - } - } else { - const { item } = await auditSpecItem(irClient, files, spec, targetDate, renewalYear) - result = item + if (!spec) { + result = { + specItemKey: `unmatched:${task.id}`, + task: task.title, + targetDate: task.dueDate, + classification: 'MANUAL_REVIEW', + folderChecked: '(no matching SHAPE spec item)', + errorMessage: `No SHAPE audit spec item matches this task's title ("${task.title}") — confirm manually`, } - } catch (err: any) { + } else if (!fileFound) { result = { specItemKey: spec.key, task: spec.task, - targetDate, - classification: 'MANUAL_REVIEW', - folderChecked: spec.folderPath.join(' > ') || 'ALL_TIME (whole file)', - errorMessage: `Audit check failed: ${err.message}`, + targetDate: task.dueDate, + classification: 'FAIL_MISSING', + folderChecked, + errorMessage: 'No ImageRight file found for this client', + } + } else { + try { + // The Policy Term folder to check is named for the renewal the task is + // preparing for, not necessarily the calendar year the task happens to be due + // in (a pre-renewal task due late one year can prep for a renewal early the + // next) — recover it from the task's own stored (negative) offset instead of + // guessing from dueDate alone. + const impliedRenewalDate = new Date(task.dueDate.getTime() - task.daysOffset * DAY_MS) + const { item } = await auditSpecItem(irClient, files, spec, task.dueDate, impliedRenewalDate.getFullYear()) + result = item + } catch (err: any) { + result = { + specItemKey: spec.key, + task: spec.task, + targetDate: task.dueDate, + classification: 'MANUAL_REVIEW', + folderChecked, + errorMessage: `Audit check failed: ${err.message}`, + } } } - result.linkedTaskId = await findLinkedTask(clientId, spec.task, targetDate) + result.linkedTaskId = task.id items.push(result) } diff --git a/ondeck/src/lib/imageright/audit-matching.ts b/ondeck/src/lib/imageright/audit-matching.ts index 22481ce..a81917c 100644 --- a/ondeck/src/lib/imageright/audit-matching.ts +++ b/ondeck/src/lib/imageright/audit-matching.ts @@ -56,6 +56,30 @@ export function matchesDocType(actualType: string, expectedTypes: string[]): boo return expectedTypes.some((t) => lower.includes(stripParenthetical(t).toLowerCase())) } +/** Strip a trailing conditional qualifier, e.g. "Claim Review (can be included with ...)" -> "Claim Review". */ +function stripTrailingParenthetical(s: string): string { + return s.replace(/\s*\([^)]*\)\s*$/, '').trim() +} + +/** + * Match a real Task.title (generated from TaskTemplate) to its corresponding + * ShapeAuditSpecItem. Task titles carry a trailing conditional qualifier not present + * in the spec's `task` field (e.g. "SHAPE Onboarding Checklist (required after first + * renewal or when changing carriers)" -> "SHAPE Onboarding Checklist"), and use + * "Claims Review"/"Claim Review" interchangeably — both handled by normalizeForMatch's + * plural/singular tolerance. Falls back to a prefix match for older/renamed templates + * (e.g. "Claim Review in conjunction with 120 day loss summary..."). + */ +export function matchTaskToSpec(taskTitle: string, specs: ShapeAuditSpecItem[]): ShapeAuditSpecItem | undefined { + const normalizedTitle = normalizeForMatch(stripTrailingParenthetical(taskTitle)) + const exact = specs.find((s) => normalizeForMatch(s.task) === normalizedTitle) + if (exact) return exact + return specs.find((s) => { + const normalizedTask = normalizeForMatch(s.task) + return normalizedTitle.startsWith(normalizedTask) || normalizedTask.startsWith(normalizedTitle) + }) +} + /** * Map a spec `doc_types` label to the file extensions it plausibly refers to. Real * ImageRight document types (e.g. "Pre-Renewal Information", "Correspondences") don't diff --git a/ondeck/src/lib/imageright/content-inspector.ts b/ondeck/src/lib/imageright/content-inspector.ts index 4ecd4ce..9b784a0 100644 --- a/ondeck/src/lib/imageright/content-inspector.ts +++ b/ondeck/src/lib/imageright/content-inspector.ts @@ -10,6 +10,11 @@ */ import MsgReader from '@kenjiuno/msgreader' import * as XLSX from 'xlsx' +// Must be imported before 'pdf-parse' — provides the DOMMatrix/Path2D/ImageData shims +// (backed by @napi-rs/canvas) that pdfjs-dist needs in Node.js; without it, pdf-parse +// throws `ReferenceError: DOMMatrix is not defined` at module-evaluation time in +// Next.js's server bundle. See pdf-parse's troubleshooting docs (Next.js/Vercel section). +import 'pdf-parse/worker' import { PDFParse } from 'pdf-parse' export interface InspectedAttachment { diff --git a/ondeck/src/lib/imageright/shape-audit-spec.ts b/ondeck/src/lib/imageright/shape-audit-spec.ts index da53eea..b1e2345 100644 --- a/ondeck/src/lib/imageright/shape-audit-spec.ts +++ b/ondeck/src/lib/imageright/shape-audit-spec.ts @@ -8,6 +8,12 @@ export const SHAPE_DESIGNATION_NAMES = ['shape', 'shape 2'] * * `windowBeforeDays`/`windowAfterDays` are null for the one ALL_TIME item * (search the entire file history instead of a date window). + * + * `daysAfterRenewal` is informational only (documents the checklist's intended timing + * per the source spec doc) — audit-engine.ts's runTaskAudit() does NOT use it to compute + * target dates. It audits the client's real, already-generated open Task rows instead, + * using each task's actual `dueDate` (itself computed elsewhere from TaskTemplate.daysOffset, + * a *negative*, pre-renewal offset — a different sign convention than this field). */ export interface ShapeAuditSpecItem { key: string @@ -77,8 +83,12 @@ export const SHAPE_AUDIT_SPEC: ShapeAuditSpecItem[] = [ keywords: ['MOD'], }, { - key: 'request_120_day_loss_runs', - task: 'Request 120 day loss runs', + // Live TaskTemplate data (task_templates.name, is_active=true) says "125", not the + // "120" this item was originally ported from SHAPE_IR_Filing_Audit_Spec.md as — + // the live template is treated as ground truth since it's what actually generates + // real Task rows. + key: 'request_125_day_loss_runs', + task: 'Request 125 day loss runs', daysAfterRenewal: 240, windowBeforeDays: 7, windowAfterDays: 7, @@ -88,7 +98,7 @@ export const SHAPE_AUDIT_SPEC: ShapeAuditSpecItem[] = [ }, { key: 'captive_claims_worksheet', - task: 'Assist with captive claims worksheet (if applicable)', + task: 'Assist with captive claims worksheet', condition: 'Conditional — only applies if client participates in a captive program', daysAfterRenewal: 245, windowBeforeDays: 21,