From 672f17b7f9e608006a3d8f0e447a71f7281d5c07 Mon Sep 17 00:00:00 2001 From: lorentz Date: Sat, 18 Jul 2026 06:34:57 -0400 Subject: [PATCH] =?UTF-8?q?chore:=20check=20in=20pending=20work=20?= =?UTF-8?q?=E2=80=94=20queue=20preferences,=20QBO=20AR=20diagnostics,=20mo?= =?UTF-8?q?bile=20engagement=20fixes,=20ops=20scripts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundles several in-progress efforts that were sitting uncommitted: - User queue-preferences (migration 087, API route, popover component) - QBO invoice soft-delete (migration 088) and AR diagnostics route - Dashboard/mobile engagement route and page adjustments - Docker Compose log-rotation config - One-off ticket/RMM investigation scripts (scripts/) - Planning docs: phase verification/pattern notes, mobile shell design spec - .gitignore: exclude local scratch financial/inventory data and Claude Code worktree/local-settings runtime state (never meant for version control) Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01W6RuWdiUiXrPK6FLBHjtpY --- .../pulse-overshell-b2-evidence/SKILL.md | 146 +++++++ .gitignore | 11 + .planning/config.json | 2 +- .../16-VERIFICATION.md | 121 ++++++ .../17-VERIFICATION.md | 94 +++++ .../20-VERIFICATION.md | 105 +++++ .../21-autotask-triage-note/21-PATTERNS.md | 364 ++++++++++++++++++ .../.gitkeep | 0 app/api/dashboard/trends/route.ts | 147 ++++++- app/api/me/queue-preferences/route.ts | 101 +++++ app/api/mobile/engagement/summary/route.ts | 2 +- app/api/mobile/engagement/trend/route.ts | 8 +- app/api/mobile/finance/route.ts | 72 +++- app/api/qbo/diagnose-ar/route.ts | 182 +++++++++ app/api/qbo/sync/route.ts | 2 +- app/dashboard/page.tsx | 35 +- app/mobile/finance/page.tsx | 7 +- components/dashboard/active-engineers.tsx | 273 +++++++++++-- .../dashboard/queue-preferences-popover.tsx | 137 +++++++ docker-compose.yml | 17 +- .../specs/2026-05-03-mobile-shell-design.md | 221 +++++++++++ lib/services/qbo-sync-service.ts | 52 ++- migrations/087_user_queue_preferences.sql | 23 ++ migrations/088_qbo_invoices_soft_delete.sql | 17 + scripts/check-699456-parse.ts | 12 + scripts/check-suspect-tickets.ts | 31 ++ scripts/diagnose-ticket-varchar-overflow.ts | 192 +++++++++ scripts/list-rmm-sites.ts | 22 ++ scripts/peek-ticket.ts | 26 ++ scripts/reanalyze-seubert-burst.ts | 105 +++++ scripts/reclassify-699456.ts | 12 + scripts/verify-blast-radius-held-bug.ts | 95 +++++ .../verify-guardian-protection-mimecast.ts | 94 +++++ scripts/verify-held-date-scope.ts | 56 +++ scripts/verify-mansfield-mimecast.ts | 109 ++++++ 35 files changed, 2801 insertions(+), 92 deletions(-) create mode 100644 .claude/skills/pulse-overshell-b2-evidence/SKILL.md create mode 100644 .planning/phases/16-eml-mime-evidence-parser/16-VERIFICATION.md create mode 100644 .planning/phases/17-mimecast-blast-radius-lookup/17-VERIFICATION.md create mode 100644 .planning/phases/20-remediation-approval-audit-safety/20-VERIFICATION.md create mode 100644 .planning/phases/21-autotask-triage-note/21-PATTERNS.md create mode 100644 .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/.gitkeep create mode 100644 app/api/me/queue-preferences/route.ts create mode 100644 app/api/qbo/diagnose-ar/route.ts create mode 100644 components/dashboard/queue-preferences-popover.tsx create mode 100644 docs/superpowers/specs/2026-05-03-mobile-shell-design.md create mode 100644 migrations/087_user_queue_preferences.sql create mode 100644 migrations/088_qbo_invoices_soft_delete.sql create mode 100644 scripts/check-699456-parse.ts create mode 100644 scripts/check-suspect-tickets.ts create mode 100644 scripts/diagnose-ticket-varchar-overflow.ts create mode 100644 scripts/list-rmm-sites.ts create mode 100644 scripts/peek-ticket.ts create mode 100644 scripts/reanalyze-seubert-burst.ts create mode 100644 scripts/reclassify-699456.ts create mode 100644 scripts/verify-blast-radius-held-bug.ts create mode 100644 scripts/verify-guardian-protection-mimecast.ts create mode 100644 scripts/verify-held-date-scope.ts create mode 100644 scripts/verify-mansfield-mimecast.ts diff --git a/.claude/skills/pulse-overshell-b2-evidence/SKILL.md b/.claude/skills/pulse-overshell-b2-evidence/SKILL.md new file mode 100644 index 0000000..2f75f7d --- /dev/null +++ b/.claude/skills/pulse-overshell-b2-evidence/SKILL.md @@ -0,0 +1,146 @@ +--- +name: pulse-overshell-b2-evidence +description: Use when running PowerShell on a Pulse-managed Windows endpoint via Datto RMM Overshell, uploading large output to Backblaze B2, and retrieving the parsed evidence — covers both the stdout-only and B2-transport pipelines, the dispatch API, webhook receiver, presigned download, and where results land in rmm_executions. +--- + +# Pulse Overshell → B2 → Retrieve + +Pulse has two PowerShell evidence pipelines, both terminating in the same +`rmm_executions` table. Pick the right one for the payload size, dispatch +via the API, then read the row back. + +## Decision: which transport? + +| Output size | Transport (`transport` column) | How results come back | +|---|---|---| +| ≤ ~50 KB stdout | `overshell_stdout` (default) | Worker polls Datto job → `raw_stdout` + `parsed_evidence` | +| Large (event logs, dumps) | `b2_upload` (LogLift) | Collector gzips → B2 PUT → webhook → Pulse downloads + slims | + +**There is no ad-hoc PowerShell input.** Only registered `RmmScript`s in +`lib/services/rmm/scripts/index.ts` can run. To add one, create +`lib/services/rmm/scripts/.ts` with a `parseOutput` that JSON-parses +a `ConvertTo-Json -Compress` tail, then add to `_all` in `index.ts` and +bump `version`. Registry test enforces uniqueness + credential-shape ban. + +## Dispatch (both transports) + +`POST /api/rmm/executions` (requires `rmm.execute` permission — admin/super-admin only): + +```ts +// site-anchored (runs on the client's WNP endpoint) +{ scriptId: 'get-ad-health', target: { type: 'site_anchor', companyId: 29861375 } } + +// asset-self (runs on a specific Datto device) +{ scriptId: 'loglift-eventlogs', + target: { type: 'asset_self', deviceUid: 'f7a8…', assetType: 'configuration', assetId: '12345' } } +``` + +Returns `{ executionId, status: 'queued' | 'running', ... }`. Rate limit: +**50 executions per user per 24h** — trips before `runQuickJob` and logs +to `analyzer_cost_audit` with `decision='blocked'`. Hard timeout: 5 min. + +## How LogLift uses B2 + +For `loglift-eventlogs` (the only B2-transport script today), Pulse +passes the collector four `runQuickJob` variables — `RunId`, `ClientId` +(Datto site uid), `WebhookUrl` (`${BETTER_AUTH_URL}/api/rmm/loglift/upload`), +`WebhookSecret` (`OPENCLAW_API_KEY`). The collector script (registered +inside Datto, not stored in Pulse) gzips its JSON and PUTs to: + +``` +{datto_site_uid}/{computer_name}/eventlogs_{YYYYMMDD_HHMMSS}.json.gz +``` + +…in bucket `B2_BUCKET` (default `wulf-audits`, region `us-west-002`). +Object-key shape is enforced by `OBJECT_KEY_REGEX` in +`lib/services/b2/client.ts` — anything else is rejected at the webhook. + +Then it POSTs to `/api/rmm/loglift/upload` with `x-openclaw-key: $WebhookSecret` +and the metadata body (`runId`, `clientId`, `computerName`, `objectKey`, +`summary`, etc. — see `docs/loglift-eventlog-pipeline-spec.md` for the +exact shape). Pulse calls `downloadToBuffer(objectKey)`, gunzips, runs +`redact()`, slims to `parsed_evidence`, and flips the row to `complete`. + +## Retrieving results + +### From an executionId you already have + +```ts +const r = await fetch(`/api/rmm/executions/${id}`).then(r => r.json()); +// r.execution.status, .raw_stdout (overshell_stdout), .parsed_evidence (both), .evidence_object_key (b2_upload) +``` + +### From SQL + +```sql +-- Most-recent successful run per (company, script): +SELECT DISTINCT ON (target_company_id, script_id) + id, target_company_id, script_id, target_hostname, transport, + evidence_object_key, completed_at, jsonb_pretty(parsed_evidence) AS parsed + FROM rmm_executions + WHERE status = 'complete' + AND completed_at >= NOW() - INTERVAL '14 days' + ORDER BY target_company_id, script_id, completed_at DESC; +``` + +### Re-fetching the original gzip from B2 + +The slim payload is in Postgres; the **full gzip stays in B2 forever** for +forensic replay. From a Node script or API route: + +```ts +import { downloadToBuffer, presignDownload } from '@/lib/services/b2/client'; + +// In-process (capped at 25 MB by MAX_DOWNLOAD_BYTES): +const buf = await downloadToBuffer(row.evidence_object_key); + +// Or hand a short-lived URL to a human / external tool: +const url = presignDownload(row.evidence_object_key, 600); // 10-minute GET +``` + +`presignDownload` / `presignUpload` validate `OBJECT_KEY_REGEX` and sign +with SigV4 against `B2_KEY_ID` / `B2_APP_KEY` from env. They throw +`B2NotConfiguredError` if either is unset. + +## Where things live + +- Dispatch: `lib/services/rmm/executor.ts` (`queueExecution`) +- Worker (stdout polling + timeout sweep): `lib/services/rmm/worker.ts` +- LogLift receiver: `lib/services/rmm/loglift-receiver.ts` → + `app/api/rmm/loglift/upload/route.ts` +- B2 SigV4 client: `lib/services/b2/client.ts` +- Script registry: `lib/services/rmm/scripts/index.ts` +- Settings (component_uids, variable name): `lib/services/rmm/settings.ts` → + `/admin/rmm-overshell` for the UI + "Re-discover" buttons +- Schema: `migrations/077_rmm_overshell.sql` + `078_loglift_uploads.sql` + (adds `transport`, `evidence_object_key`, `run_id`) +- Specs: `docs/rmm-overshell-evidence-spec.md`, `docs/loglift-eventlog-pipeline-spec.md` + +## Gotchas + +- **The worker is a side-effect import.** Anything that needs it running + must `import '@/lib/services/rmm/worker'` somewhere on the server. The + POST `/api/rmm/executions` route already does this. Don't eager-import + from hot paths or shared utilities. +- **Worker ignores `transport='b2_upload'` rows for stdout polling.** The + webhook is the completion event. If the webhook never arrives, the + 5-minute timeout sweep marks the row `timeout`. +- **Redaction runs twice.** `lib/services/analyzer/itglue-redact.ts:redact()` + strips anything keyed `/password|secret|key|token|credential|api[_-]?key/i` + before persistence and again before the LLM prompt sees it. Don't try + to surface those fields — they're gone by the time you read the row. +- **B2 download cap is 25 MB hard.** Refuses content-length over the cap + and aborts mid-stream if it exceeds. Decompress cap is 100 MB ISIZE + (zip-bomb defense). +- **Object-key shape is strict.** Custom prefixes won't work — must be + `{client_id_or_uuid}/{computer_name}/eventlogs_{timestamp}.json.gz`. + If you need a new payload type, add a new key regex + a new transport + rather than loosening the existing one. +- **Auto-audit fires only on single-match Configurations.** Multi-match + hostnames land the evidence but skip the audit (logged with + `rmm.loglift.matched`, no `audit_triggered`). +- **Webhook is not HMAC-signed** — `x-openclaw-key` is the auth boundary. + If you expose the endpoint to a new collector, rotate `OPENCLAW_API_KEY`. +- **`B2_*` env vars are required.** `isB2Configured()` checks only + `B2_KEY_ID + B2_APP_KEY`; bucket/region/endpoint fall back to + `wulf-audits` / `us-west-002` / `s3.us-west-002.backblazeb2.com`. diff --git a/.gitignore b/.gitignore index 4cbda39..1a36f3a 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,14 @@ routes.ts # fonts *.woff2 + +# local scratch data (real customer/financial data — never commit) +dev/fin/ +dev/seubert-laptops.csv + +# Claude Code local runtime state — agent worktree checkouts, personal +# permission overrides, and the scheduler lock are per-machine, not project +# config. .claude/skills/ (project skills) is intentionally NOT ignored. +.claude/worktrees/ +.claude/settings.local.json +.claude/scheduled_tasks.lock diff --git a/.planning/config.json b/.planning/config.json index eb3a86b..74e39b0 100644 --- a/.planning/config.json +++ b/.planning/config.json @@ -36,4 +36,4 @@ "agent_skills": {}, "mode": "yolo", "granularity": "standard" -} \ No newline at end of file +} diff --git a/.planning/phases/16-eml-mime-evidence-parser/16-VERIFICATION.md b/.planning/phases/16-eml-mime-evidence-parser/16-VERIFICATION.md new file mode 100644 index 0000000..6544933 --- /dev/null +++ b/.planning/phases/16-eml-mime-evidence-parser/16-VERIFICATION.md @@ -0,0 +1,121 @@ +--- +phase: 16-eml-mime-evidence-parser +verified: 2026-07-15T14:44:12Z +status: passed +score: 8/8 must-haves verified +overrides_applied: 0 +--- + +# Phase 16: EML/MIME Evidence Parser Verification Report + +**Phase Goal:** Given a ticket's attachments, Pulse selects the correct original reported message and parses its RFC822/MIME structure into normalized, actionable evidence — without ever executing or fetching anything from the message. +**Verified:** 2026-07-15T14:44:12Z +**Status:** passed +**Re-verification:** No — initial verification + +## Goal Achievement + +### Observable Truths (ROADMAP Success Criteria + PLAN must_haves, merged) + +| # | Truth | Status | Evidence | +|---|-------|--------|----------| +| 1 | Given synthetic fixtures with both `rfc.eml` and `OriginatingEmail.eml` present, selection picks `rfc.eml` matching by `message/rfc822` content-type, not filename alone | ✓ VERIFIED | `lib/services/eml-parser.ts:50-65` `selectOriginalMessage` filters `isMessageRfc822` first, then matches exact `rfc.eml`; test `eml-parser.test.ts:28-37` asserts tier 1 + case-insensitive tier 1; also covers KnowBe4 tier 2 (`:39-43`) and OriginatingEmail-only tier 3 (`:45-49`) and ambiguous/null cases (`:51-69`). All pass live (`npx vitest run` — 49/49 across the 4 phase test files). | +| 2 | Parsing a synthetic `.eml` fixture produces normalized headers (From/displayName/senderEmail/senderDomain/Reply-To/Return-Path/To/Cc/Subject/Date/Message-ID/Received chain, SPF/DKIM/DMARC), URLs, and attachment metadata (name/content-type/size/hash) | ✓ VERIFIED | `parseEml` (`eml-parser.ts:227-282`) builds exactly this shape from `mailparser`'s `ParsedMail`; test `eml-parser.test.ts:137-164` asserts every field against `RICH_MULTIPART_EML`, including `authResults` structured verdicts (not raw text), sha256 checksum format, and `related` flag. Runs against real `simpleParser` (no mocking of the parser itself) — not a tautology. | +| 3 | The parser never executes or fetches any URL found in a message — verified by tests asserting no outbound network calls happen during parsing | ✓ VERIFIED | `eml-parser.test.ts:188-197` spies on `global.fetch` across 5 fixture parses and asserts zero calls; `extractUrls`/`buildBodyPreview` are pure string operations (`eml-parser.ts:140-184`) with no fetch/render path. Orchestration-level (service) invariant separately verified below (Truth 6). | +| 4 | Parsed output includes a sanitized/truncated body preview stored alongside raw evidence, distinct from the full raw body | ✓ VERIFIED | `buildBodyPreview` (`eml-parser.ts:176-184`) truncates to `MAX_BODY_PREVIEW_LENGTH=500` and strips HTML via `stripHtmlToText`; test `eml-parser.test.ts:128-133,178-181` confirms truncation and distinctness on a 2000-char fixture. Raw bytes are never persisted to Postgres — only to B2 (`raw_ref` is an object key, `phishing-eml-service.ts:97-106,142-157`), consistent with D-05. | +| 5 | `npx vitest run` for the new parser test file passes using synthetic fixtures only (no real customer email) | ✓ VERIFIED | Ran live: `eml-parser.test.ts` 26/26 pass. A dedicated test (`eml-parser.test.ts:205-218`) asserts no fixture contains `wulfconsulting.com` and all use RFC 2606 reserved `.test`/`.com`-fake (`evil-example.test`) domains. `deferred-items.md` documents the 2 pre-existing unrelated `itglue-search.test.ts` failures (confirmed via `git log` — last touched at commit `a0a6e7f`/`8f8b5ab`, predating all Phase 16 commits). | +| 6 | (16-02/16-03 supporting truth) AutotaskClient.getAttachmentContent reads `response.items?.[0]`, not `.item`; b2 EML_OBJECT_KEY_REGEX enforces path-traversal-safe `.eml` keys in parallel with the untouched LogLift regex; migration 099 adds `indicators.metadata` JSONB | ✓ VERIFIED | `autotask-client.ts:443-456` reads `response.items?.[0] ?? null`; test asserts an `{item:...}`-shaped response yields null (guards the exact regression risk called out in the plan). `b2/client.ts:41-42` adds `EML_OBJECT_KEY_REGEX` beside the unmodified `OBJECT_KEY_REGEX` (`:31-32`); `presignDownload`/`presignUpload`/`downloadToBuffer` (`:155-218`) take an optional `keyRegex` param defaulting to `OBJECT_KEY_REGEX`; `rmm/executor.ts:337`'s existing 2-arg call site still compiles (confirmed via `tsc --noEmit`, exit 0). Migration 099 applied live: `information_schema.columns` on the dev DB (`pulse-postgres`) reports `indicators.metadata` as `jsonb`. | +| 7 | Orchestration service (16-03) wires list→select→fetch→(B2 gated)→parse→persist end to end, writing one `messages` row + `indicators` rows with D-07 metadata, and no-op (no throw) when no `.eml` attachment or B2 unconfigured | ✓ VERIFIED | `phishing-eml-service.ts:49-224` imports and calls `getAutotaskClient().getAttachments`, `selectOriginalMessage`, `getAttachmentContent`, `isB2Configured`/`presignUpload`/`EML_OBJECT_KEY_REGEX`, `parseEml` — exact function signatures match Plan 01/02's exports (verified by reading both source files side by side). `messages` INSERT (`:142-157`) and 3 `indicators` INSERT loops (`:164-211`) match migration 097/099's actual column list (`report_id, message_id, headers, urls, attachments, body_preview, raw_ref` / `message_id, indicator_type, value, metadata`). Test suite (`phishing-eml-service.test.ts`, 6/6 passing) exercises the happy path (real `parseEml` invoked on `RICH_MULTIPART_EML`, only DB/Autotask/B2 boundaries mocked — not tautological), no-eml no-op, B2-configured/unconfigured branches, no-network invariant, and indicator metadata shape. | +| 8 | No outbound network call is made to any URL found in the message, end to end (service level) | ✓ VERIFIED | `phishing-eml-service.test.ts:155-170` asserts `global.fetch` is called exactly once (the B2 presigned PUT) and never with the fixture's embedded body URL (`http://evil-example.test/verify`). Source review confirms the only `fetch(` call in `phishing-eml-service.ts` targets `uploadUrl` (a B2-presigned URL Pulse itself constructed), never a value derived from `normalized.urls`. | + +**Score:** 8/8 truths verified + +### Required Artifacts + +| Artifact | Expected | Status | Details | +|----------|----------|--------|---------| +| `lib/services/eml-parser.ts` | parseEml, selectOriginalMessage, parseAuthResults, extractUrls, buildBodyPreview, NormalizedMessage (min 120 lines) | ✓ VERIFIED | 282 lines; all 5 functions + types exported exactly per plan interface contract | +| `lib/services/eml-parser.fixtures.ts` | synthetic fixtures for all 3 selection tiers + parse fixtures | ✓ VERIFIED | Exists, imported and exercised by both `eml-parser.test.ts` and `phishing-eml-service.test.ts`; RFC 2606-safe synthetic content only | +| `lib/services/eml-parser.test.ts` | EVID-02/03/04 vitest coverage incl. no-network spy + 3-tier selection (min 100 lines) | ✓ VERIFIED | 219 lines, 26 tests, all passing live | +| `lib/services/autotask-client.ts` | getAttachmentContent(entityName, entityId, attachmentId) | ✓ VERIFIED | Present at line 443, reads `items?.[0] ?? null` | +| `lib/services/autotask-client.test.ts` | first AutotaskClient unit coverage — items[0] behavior (min 30 lines) | ✓ VERIFIED | 73 lines, 3 tests, all passing | +| `lib/services/b2/client.ts` | EML_OBJECT_KEY_REGEX + parameterized key validation | ✓ VERIFIED | Present, OBJECT_KEY_REGEX untouched (git-diff-style visual check against current content confirms LogLift regex line unchanged), all 3 functions parameterized | +| `migrations/099_indicators_metadata.sql` | ALTER TABLE indicators ADD COLUMN metadata JSONB | ✓ VERIFIED | Present, applied live to dev DB (confirmed via information_schema query) | +| `lib/services/phishing-eml-service.ts` | parseAndStoreMessage orchestration (min 90 lines) | ✓ VERIFIED | 224 lines, full list→select→fetch→size-guard→B2→parse→persist flow | +| `lib/services/phishing-eml-service.test.ts` | orchestration coverage with mocked autotask/b2/postgres incl. no-network + B2-gated + no-eml no-op (min 70 lines) | ✓ VERIFIED | 195 lines, 6 tests, all passing | + +### Key Link Verification + +| From | To | Via | Status | Details | +|------|-----|-----|--------|---------| +| `eml-parser.ts` | `mailparser` | `simpleParser` import w/ `checksumAlgo: 'sha256'` | ✓ WIRED | `eml-parser.ts:21,236` | +| `eml-parser.test.ts` | `global.fetch` | `vi.spyOn` zero-call assertion | ✓ WIRED | `eml-parser.test.ts:188-197` | +| `autotask-client.ts getAttachmentContent` | Autotask `Tickets/{id}/Attachments/{attachmentId}` | `makeApiCall` GET, `items?.[0]` | ✓ WIRED | `autotask-client.ts:443-456`; test confirms both shapes | +| `b2/client.ts presignUpload` | `EML_OBJECT_KEY_REGEX` | optional `keyRegex` param | ✓ WIRED | `b2/client.ts:165-173` | +| `phishing-eml-service.ts` | `eml-parser.ts` | `parseEml`+`selectOriginalMessage` import | ✓ WIRED | `phishing-eml-service.ts:27`; both invoked in the real flow (not stubbed in production code) | +| `phishing-eml-service.ts` | `AutotaskClient.getAttachmentContent` | `getAutotaskClient().getAttachmentContent(...)` | ✓ WIRED | `phishing-eml-service.ts:65-69` | +| `phishing-eml-service.ts` | `messages`/`indicators` tables | `postgresClient.query` INSERT ... RETURNING id::text | ✓ WIRED | `phishing-eml-service.ts:142-157,166-211`; column lists match migration 097+099 schema exactly | +| `phishing-eml-service.ts` | B2 (`presignUpload`+`EML_OBJECT_KEY_REGEX`) | `isB2Configured` gate then self-PUT | ✓ WIRED | `phishing-eml-service.ts:97-122`; gated, graceful-degrades on failure | + +### Data-Flow Trace (Level 4) + +Not applicable in the traditional UI-rendering sense — this phase is a pure backend service/library. Instead, traced data flow through the orchestration pipeline directly: + +| Stage | Input | Output | Verified Real (not hardcoded) | +|-------|-------|--------|-------------------------------| +| `getAttachments` → `selectOriginalMessage` | live Autotask attachment list | selected `Attachment \| null` | ✓ Algorithm is a real filter/find chain over the input array, not a static return | +| `getAttachmentContent` → `Buffer.from(data,'base64')` | live Autotask base64 payload | decoded raw bytes | ✓ Real base64 decode, test proves items[0]-vs-item distinction | +| `parseEml` → `messages` INSERT | real `simpleParser` output | JSONB headers/urls/attachments payload | ✓ `headersPayload` built field-by-field from `normalized.*`, not a static object; test asserts `authResults` reaches the persisted param | +| `normalized.attachments/urls/from` → `indicators` INSERT loops | parsed message | per-indicator rows w/ metadata | ✓ Loops iterate real arrays from the parse result; test asserts attachment_hash metadata shape from an actual parsed fixture | + +No hollow/static-return patterns found in the traced path. + +### Behavioral Spot-Checks + +| Behavior | Command | Result | Status | +|----------|---------|--------|--------| +| Full phase-16 vitest suite | `npx vitest run lib/services/eml-parser.test.ts lib/services/autotask-client.test.ts lib/services/b2/client.test.ts lib/services/phishing-eml-service.test.ts` | 4 files, 49/49 tests passed | ✓ PASS | +| Repo-wide type check | `npx tsc --noEmit --pretty` | exit 0, no output | ✓ PASS | +| Full test suite (regression check) | `npm test` | 25/26 files pass, 282/284 tests pass; only pre-existing unrelated `itglue-search.test.ts` failures (confirmed via `git log` predating Phase 16 commits) | ✓ PASS (no phase-16 regressions) | +| Migration 099 applied to dev DB | `docker exec pulse-postgres psql ... information_schema.columns` | returns `jsonb` | ✓ PASS | +| `rmm/executor.ts` presignUpload call site unaffected | `grep presignUpload lib/services/rmm/executor.ts` + tsc clean | 2-arg call, `presignUpload(objectKey, 1800)`, compiles | ✓ PASS | + +### Probe Execution + +No `scripts/*/tests/probe-*.sh` probes declared or discovered for this phase; not a migration/tooling phase in that sense. SKIPPED (no probe files apply — verification instead relied on the project's own vitest suite, run directly by the verifier, not narrated by SUMMARY.md). + +### Requirements Coverage + +| Requirement | Source Plan | Description | Status | Evidence | +|-------------|-------------|-------------|--------|----------| +| EVID-02 | 16-01 | Prefer `rfc.eml` over `OriginatingEmail.eml`, matching by content-type not filename alone | ✓ SATISFIED | `selectOriginalMessage` three-tier algorithm + full test coverage | +| EVID-03 | 16-01, 16-02, 16-03 | Parse RFC822/MIME into normalized headers/auth-verdicts/URLs/attachment metadata, persisted | ✓ SATISFIED | `parseEml` + `messages`/`indicators` persistence, both tested | +| EVID-04 | 16-01, 16-02, 16-03 | Sanitized/truncated body preview stored alongside raw evidence; never fetch/execute message content | ✓ SATISFIED | `buildBodyPreview` + B2 raw storage (D-05) + no-network spy at both parser and service level | + +Note: `.planning/REQUIREMENTS.md` still shows EVID-02/03/04 as unchecked/"Pending" — this appears to be a tracking-doc staleness issue (the doc is not updated by the execute-phase workflow), not evidence of non-completion. All three requirements are satisfied by the code and tests as verified above. + +### Anti-Patterns Found + +None. Scanned all 10 phase-modified/created files for `TBD|FIXME|XXX|TODO|HACK|PLACEHOLDER`, "not yet implemented", "coming soon" — zero matches. + +### Deviations Reviewed (from SUMMARY.md, checked against CLAUDE.md conventions) + +1. **16-01: hand-rolled HTML stripper instead of `html-to-text`.** Acceptable — avoids depending on an unpinned transitive dependency; consistent with "don't introduce dependencies not required" project posture; fully test-covered. +2. **16-01: test title renamed for `-t` filter discoverability.** Cosmetic, no behavior change. +3. **16-02: migration 099 applied via direct `docker exec` instead of `scripts/apply-migrations.sh`.** Consistent with CLAUDE.md's own documented caveat ("check first; behavior varies") — the script's hardcoded `MIGRATIONS_DIR` didn't see the worktree's file; the same real credentials (`pulse_user`/`pulse_autotask`) were used, not fallback defaults. Verified live on the dev DB. +4. **16-03: self-PUT to B2 (first instance of Pulse's own server code PUTting to B2, vs. handing a presigned URL to an external collector).** Explicitly anticipated and researched in 16-RESEARCH.md as a deliberate new pattern, not an ad hoc deviation; gated behind `isB2Configured()` with graceful degrade on failure. +5. **16-03: Task 1/Task 2 executed as separate implementation-then-test-suite commits rather than interleaved RED/GREEN within one task.** Matches how the plan's own task structure was written (Task 1's verify was tsc-only; Task 2's verify included vitest). No coverage gap — the full suite passes and covers real behavior, not a rubber-stamp. + +None of these deviations reduce scope or introduce risk beyond what's already accepted in the phase's own threat model. + +### Human Verification Required + +None. This phase is a pure backend library + orchestration service with no UI, no new API route, and no external-service live-credential dependency (Autotask/B2/Postgres are all mocked in tests; the live on-demand trigger route is explicitly deferred to Phase 18). All success criteria are mechanically verifiable via source review + live test execution, both performed above. + +### Gaps Summary + +No gaps found. All 3 requirements (EVID-02, EVID-03, EVID-04) are implemented, wired end-to-end from the pure parser (16-01) through supporting infrastructure (16-02) to the orchestration service (16-03), and covered by tests that exercise real parsing logic (not mocked-to-pass tautologies) with mocks confined to true I/O boundaries (Postgres, Autotask HTTP, B2 HTTP). Live verification (not just SUMMARY.md narrative) confirms: 49/49 phase-specific tests pass, `tsc --noEmit` is clean, the dev-DB migration is actually applied, and the pre-existing unrelated `itglue-search.test.ts` failures are correctly out of scope (confirmed via git history predating this phase). + +--- +_Verified: 2026-07-15T14:44:12Z_ +_Verifier: Claude (gsd-verifier)_ diff --git a/.planning/phases/17-mimecast-blast-radius-lookup/17-VERIFICATION.md b/.planning/phases/17-mimecast-blast-radius-lookup/17-VERIFICATION.md new file mode 100644 index 0000000..67a8ed4 --- /dev/null +++ b/.planning/phases/17-mimecast-blast-radius-lookup/17-VERIFICATION.md @@ -0,0 +1,94 @@ +--- +phase: 17-mimecast-blast-radius-lookup +verified: 2026-07-15T14:35:00Z +status: passed +score: 5/5 must-haves verified +overrides_applied: 0 +--- + +# Phase 17: Mimecast Blast Radius Lookup Verification Report + +**Phase Goal:** Pulse can ask "how far did this message spread" via a Mimecast blast-radius abstraction when Mimecast is configured, and gets a clean `unavailable` signal — never a crash or a block — when it isn't. +**Verified:** 2026-07-15T14:35:00Z +**Status:** passed +**Re-verification:** No — initial verification + +## Goal Achievement + +### Observable Truths + +| # | Truth | Status | Evidence | +|---|-------|--------|----------| +| 1 | `isMimecastConfigured()` returns true only when both `MIMECAST_CLIENT_ID` and `MIMECAST_CLIENT_SECRET` are set, false otherwise | VERIFIED | `lib/services/mimecast-client.ts:647-649` — `!!(process.env.MIMECAST_CLIENT_ID && process.env.MIMECAST_CLIENT_SECRET)`. Tested in `mimecast-client.test.ts` (4 cases: neither/only-ID/only-secret/both), all pass. | +| 2 | `getBlastRadius()` returns `status:'unavailable' reason:'not_configured'` synchronously (no Mimecast call) when unconfigured | VERIFIED | `mimecast-blast-radius.ts:92-95` returns before any client construction. Test asserts `getMimecastClientMock`/all 3 fan-out mocks `.not.toHaveBeenCalled()`. | +| 3 | `getBlastRadius()` when configured returns normalized matched/delivered/held/rejected/clicked counts + per-recipient status array, built by fanning out to `searchDeliveredMessages` + `getHeldMessages` + `getThreatEvents` UNCONDITIONALLY (not gated behind a `getMessageInfo` miss — corrected D-01 / Pitfall 1) | VERIFIED | `mimecast-blast-radius.ts:104-128` — `getMessageInfo` (line 109-111) is called only for supplementary body/header evidence when `messageId` present, its result is discarded (not awaited into a variable used downstream), and is NOT inside any conditional that gates the `Promise.all([...])` fan-out at line 118, which always runs. Merge logic at 130-184 builds real counts, not fallback zeros. Test "merges delivered/held/threat-event fixtures..." confirms counts (`matched:2, delivered:1, held:1, rejected:1, clicked:1`) and `perRecipient` array. | +| 4 | An unexpected error thrown during the fan-out degrades to `status:'unavailable' reason:'lookup_failed'` rather than propagating | VERIFIED | `mimecast-blast-radius.ts:188-194` catch block. Test "degrades to unavailable/lookup_failed (never throws) when a fan-out call rejects" — `getHeldMessagesMock.mockRejectedValue(...)`, asserts resolved (not rejected) result equals `{status:'unavailable', reason:'lookup_failed', error:'Mimecast API timeout'}`. | +| 5 | A repeated lookup for the same message identity within the cache TTL returns the cached result without re-calling any MimecastClient method | VERIFIED | `mimecast-blast-radius.ts:101-102` — `getCachedData` checked and returned BEFORE `getMimecastClient()` is called at line 105. Test "returns the cached result on a cache hit..." asserts all 3 fan-out mocks and `setCachedDataMock` not called. | + +**Score:** 5/5 truths verified + +### Required Artifacts + +| Artifact | Expected | Status | Details | +|----------|----------|--------|---------| +| `lib/services/mimecast-client.ts` | `isMimecastConfigured()` + `_resetMimecastClient()` config gate / test seam, existing exports untouched | VERIFIED | Lines 647-649 (`isMimecastConfigured`), 651-655 (`_resetMimecastClient`) added directly above unmodified `getMimecastClient()` (657-672) and `getMimecastClientForTenant()` (674+). | +| `lib/services/mimecast-blast-radius.ts` | `getBlastRadius()` orchestration + `BlastRadiusInput`/`BlastRadiusResult` types | VERIFIED | 195 lines; exports `getBlastRadius`, `BlastRadiusInput`, `BlastRadiusResult` (discriminated union) exactly as specced. | +| `lib/services/mimecast-client.test.ts` | Unit tests for `isMimecastConfigured()` + `getMimecastClient()` throw/cache behavior | VERIFIED | 63 lines, 7 tests, all pass (`npx vitest run` confirmed). | +| `lib/services/mimecast-blast-radius.test.ts` | Unit tests for config gate, fan-out merge, never-throw, cache-hit | VERIFIED | 227 lines, 6 tests, all pass. | + +### Key Link Verification + +| From | To | Via | Status | Details | +|------|----|----|--------|---------| +| `mimecast-blast-radius.ts` | `mimecast-client.ts` | `import { isMimecastConfigured, getMimecastClient, type MimecastDeliveredMessage, type MimecastHeldMessage } from './mimecast-client'` | WIRED | Import present (line 37-42); both interface types confirmed exported from `mimecast-client.ts` (lines 52, 76, 92 — `MimecastThreatEvent`, `MimecastHeldMessage`, `MimecastDeliveredMessage`). | +| `mimecast-blast-radius.ts` | `redis-client.ts` | `import { getCachedData, setCachedData } from './redis-client'` | WIRED | Import present (line 43); both functions called and awaited correctly (cache-check before client construction, cache-write after successful merge only). | + +### D-05 Multi-Tenant Comment Verification + +`grep -qi "D-05" lib/services/mimecast-blast-radius.ts` → present. The module doc-comment (lines 25-34) explicitly states: "KNOWN LIMITATION — MULTI-TENANT GAP (D-05): this module uses only the single global env-var-configured getMimecastClient(), NOT the per-company `mimecast_tenants` table / getMimecastClientForTenant(). Reports belonging to companies with their own registered Mimecast tenant ... will return `status: 'unavailable'`..." This is a genuine code comment in the shipped file, not just a plan/summary claim. + +### Pitfall-Avoidance Verification (17-RESEARCH.md's 3 documented pitfalls) + +| Pitfall | Research Concern | Shipped-Code Verification | +|---------|------------------|---------------------------| +| #1 — `getMessageInfo()` has no status/counts; fan-out must be unconditional, not gated on a miss | Implementer might skip fan-out when `getMessageInfo` hits | Confirmed avoided: `getMessageInfo` call (line 110) is a bare `await` whose return value is discarded; the `Promise.all` fan-out (lines 118-128) is unconditional — no `if` branch separates "exact match" from "fallback." Code comment at line 107-108 explicitly documents why. | +| #2 — `getThreatEvents()` click derivation is best-effort, not confirmed-zero | Risk of overstating confidence in `clicked: 0` | Confirmed avoided: doc-comment (b) at lines 17-23 states `clicked: 0` means "no click-type threat event found... NOT confirmed zero clicks." `isClickEvent()` helper (line 88-90) comment references D-02 explicitly. | +| #3 — Multi-tenant gap silently missed | Risk of a silent single-tenant assumption | Confirmed avoided: D-05 comment present and specific (see above); T-17-03 in the plan's threat model documents the accepted risk; not touched by this phase per explicit scope. | + +### Requirements Coverage + +| Requirement | Source Plan | Description | Status | Evidence | +|-------------|-------------|-------------|--------|----------| +| BLAST-01 | 17-01-PLAN.md | Query blast-radius abstraction for delivery data when configured, keyed on message ID/sender/recipient/subject/date-window | SATISFIED | `getBlastRadius()` implemented and tested per Truth #3 above; ROADMAP.md and REQUIREMENTS.md both mark BLAST-01 `[x]`/`Complete`. | +| BLAST-02 | 17-01-PLAN.md | Not configured → `status: unavailable`, never blocks; unexpected error also degrades | SATISFIED | Truths #2 and #4 above; both paths tested with explicit call-count/rejection assertions. | + +No orphaned requirements found for Phase 17 in REQUIREMENTS.md (only BLAST-01/BLAST-02 map to this phase). + +### Anti-Patterns Found + +None. Scanned all 4 phase-modified/created files (`mimecast-client.ts` diff region, `mimecast-blast-radius.ts`, `mimecast-blast-radius.test.ts`, `mimecast-client.test.ts`) for `TBD|FIXME|XXX|TODO|HACK|PLACEHOLDER|placeholder|not yet implemented` — zero matches. + +### Behavioral Spot-Checks + +| Behavior | Command | Result | Status | +|----------|---------|--------|--------| +| New unit tests pass in isolation | `npx vitest run lib/services/mimecast-blast-radius.test.ts lib/services/mimecast-client.test.ts` | 2 files, 13 tests, all passed | PASS | +| Type-check clean | `npx tsc --noEmit --pretty` | No output / exit 0 | PASS | +| Full suite has no new regressions | `npm test` | 27 passed / 1 failed file (`itglue-search.test.ts`, 2 tests) — confirmed pre-existing via `git log` (`a0a6e7f`, predates this phase's commits `8b032c3`/`efbc437`) and unrelated to any file this phase touched | PASS (pre-existing failure correctly excluded per task instructions) | + +### Test Isolation / Mocking Verification + +Both test files declare `vi.mock('./mimecast-client', ...)` / `vi.mock('./redis-client', ...)` (blast-radius test) and import the real `mimecast-client.ts` module only for the config-gate/factory tests (which intentionally exercise the real singleton with env-var manipulation and `_resetMimecastClient()` — no network egress since `getMimecastClient()` only constructs the object, doesn't call out). No `MIMECAST_CLIENT_ID`/`MIMECAST_CLIENT_SECRET` real credentials are referenced; no live HTTP calls are made in either test file (confirmed by reading both files in full — no `fetch`/`request`/network imports present). Redis is fully mocked (`getCachedDataMock`/`setCachedDataMock`). + +### Human Verification Required + +None. This phase produces no UI, no HTTP route, and no externally-observable runtime behavior beyond the unit-testable function contract — all Success Criteria are objectively verifiable via code + tests. + +### Gaps Summary + +No gaps found. All 5 derived truths verified, both roadmap Success Criteria requirements (BLAST-01, BLAST-02) satisfied, all 3 documented research pitfalls confirmed avoided in the shipped code (not just claimed in SUMMARY.md), D-05 comment confirmed present in the actual file, cache-short-circuit-before-client-call confirmed via call-count assertions, and the full test suite has zero new regressions (the 2 failing `itglue-search.test.ts` tests are confirmed pre-existing and unrelated). + +--- + +*Verified: 2026-07-15T14:35:00Z* +*Verifier: Claude (gsd-verifier)* diff --git a/.planning/phases/20-remediation-approval-audit-safety/20-VERIFICATION.md b/.planning/phases/20-remediation-approval-audit-safety/20-VERIFICATION.md new file mode 100644 index 0000000..d30a3f5 --- /dev/null +++ b/.planning/phases/20-remediation-approval-audit-safety/20-VERIFICATION.md @@ -0,0 +1,105 @@ +--- +phase: 20-remediation-approval-audit-safety +verified: 2026-07-16T11:00:00Z +status: passed +score: 6/6 must-haves verified +overrides_applied: 0 +--- + +# Phase 20: Remediation Approval & Audit Safety Verification Report + +**Phase Goal:** Deliver a remediation/approval/audit-safety layer where recommended +remediation actions are proposed-only until an operator explicitly approves them, +remediation execution is idempotent and gated, false-positive marking is +conflict-guarded, and every state-changing action (classify/approve/remediate/ +mark-false-positive) is audit-logged and permission-gated. + +**Verified:** 2026-07-16T11:00:00Z +**Status:** passed +**Re-verification:** No — initial verification + +## Goal Achievement + +### Observable Truths + +| # | Truth | Status | Evidence | +|---|-------|--------|----------| +| 1 | REMED-01: Recommended actions are `proposed`, never auto-executed | VERIFIED | `lib/services/campaign-classifier.ts` has no INSERT into `remediation_actions`; `remediation_actions.status` defaults to `'proposed'` in migration 097; only `approveRemediationActions` creates `'approved'` rows, only on explicit operator call. | +| 2 | REMED-02: Operator can approve via `POST /approve`, recording approver, timestamp, exact params | VERIFIED | `app/api/phishing/campaigns/[id]/approve/route.ts` gates `requirePermission('phishing','approve')`, captures actor from `session.user.email` (never request body), delegates to `approveRemediationActions` which inserts `approved_by=actor, approved_at=NOW(), params=$3::jsonb` per action, validated against latest `classifications.recommended_actions`. Unit tests confirm reject-if-not-recommended and reject-if-no-classification. | +| 3 | REMED-03: `POST /remediate` proceeds only for approved actions, non-destructive-by-default (simulated), never silently succeeds | VERIFIED | `remediateApprovedActions` throws `RemediationValidationError` when zero `remediation_actions` rows exist (route maps to 400). Only `status='approved'` rows transition; effect is simulated (`UPDATE ... status='completed'`, no external provider call per D-01, confirmed via grep — 0 matches for `not_implemented` outside comments and no HTTP/client calls to Mimecast/other providers in this file). | +| 4 | REMED-04: Re-running remediation is idempotent | VERIFIED | `remediateApprovedActions` selects `FOR UPDATE` and only acts on `status='approved'` rows; already-`completed` rows are skipped (no UPDATE, no audit write). Test `is idempotent: a second call transitions nothing and writes no second audit row` asserts 0 additional UPDATE calls and audit-write count stays at 2 across two calls. | +| 5 | REMED-05: Operator can mark false positive via API, blocked when remediation approved/completed exists | VERIFIED | `mark-false-positive/route.ts` gated `requirePermission('phishing','approve')`, delegates to `markCampaignFalsePositive`, which runs a `SELECT ... WHERE status IN ('approved','completed') FOR UPDATE` guard before any write; a hit throws `RemediationConflictError` -> route returns 409. Test confirms guard trips and confirms happy-path sets `campaigns.status='false_positive'`. | +| 6 | REMED-06: Every state-changing action (classify/approve/remediate/mark-false-positive) writes an audit event | VERIFIED | `writeAuditEvent` is the single INSERT path into `audit_events` (grep confirms no other file inserts into this table). All three remediation-service functions call it inside the same `postgresClient.transaction` as their state write (atomic — rollback discards both). `classify/route.ts` was edited to call `writeAuditEvent({..., eventType:'campaign_classified'})` after `classifyCampaign()` succeeds, closing the fourth action. | + +**Score:** 6/6 truths verified + +### Required Artifacts + +| Artifact | Expected | Status | Details | +|----------|----------|--------|---------| +| `lib/services/phishing-audit.ts` | `writeAuditEvent(input, client?)` single audit insert path | VERIFIED | 55 lines, parameterized INSERT (`$1`-`$4`), returns `RETURNING id::text`, accepts optional transaction client. | +| `lib/services/remediation-service.ts` | approve/remediate/mark-false-positive + typed errors | VERIFIED | 279 lines. Exports `approveRemediationActions`, `remediateApprovedActions`, `markCampaignFalsePositive`, `RemediationValidationError`, `RemediationConflictError` — all present and match plan signatures. | +| `lib/services/remediation-service.test.ts` | idempotency/audit/D-04/recommended-only proofs | VERIFIED | 259 lines, 8 real behavioral test cases (not tautological — fake transaction client records actual SQL calls and asserts on them). | +| `lib/services/phishing-audit.test.ts` | writer proof | VERIFIED | 3 tests: standalone insert, injected-client routing. | +| `lib/permissions.ts` | grants approve+remediate to admin/super-admin only | VERIFIED | `superAdminRole` and `adminRole` both `phishing: ["read","analyze","approve","remediate"]`; `userRole` remains `phishing: ["read"]`. `hasPermission()` enforces this at runtime (not just declared) — verified by reading `lib/auth-utils.ts` `requirePermission()` which calls `hasPermission(userRole, resource, action)` and returns 403 on failure. | +| `app/api/phishing/campaigns/[id]/approve/route.ts` | POST approve endpoint | VERIFIED | 77 lines. Permission gate, UUID guard, JSON body validation, actor from session, error mapping (400/409/500), delegates to service. | +| `app/api/phishing/campaigns/[id]/remediate/route.ts` | POST remediate endpoint | VERIFIED | 64 lines. Same shell, `phishing:remediate` gate, no body required. | +| `app/api/phishing/campaigns/[id]/mark-false-positive/route.ts` | POST mark-false-positive endpoint | VERIFIED | 76 lines. `phishing:approve` gate, optional-body tolerant JSON parse, 409 on conflict. | +| `app/api/phishing/campaigns/[id]/classify/route.ts` | audit event wiring | VERIFIED | Edited to destructure `session`, import `writeAuditEvent`, call it post-classify with `eventType:'campaign_classified'`. | + +### Key Link Verification + +| From | To | Via | Status | Details | +|------|-----|-----|--------|---------| +| `remediation-service.ts` | `audit_events` | `writeAuditEvent(client)` inside `postgresClient.transaction` | WIRED | All three functions call `writeAuditEvent(..., client)` using the same transaction client as their state write — atomic. | +| `remediation-service.ts` | `classifications.recommended_actions` | latest-classification read validates approvable types | WIRED | `approveRemediationActions` selects `ORDER BY created_at DESC LIMIT 1` and rejects non-recommended action types before any insert. | +| `remediation-service.ts` | `remediation_actions.status` | `FOR UPDATE` status filter drives idempotent completion | WIRED | Confirmed via grep + test (`FOR UPDATE` present in both `remediateApprovedActions` and the D-04 guard in `markCampaignFalsePositive`). | +| `approve/route.ts` | `approveRemediationActions` | `requirePermission('phishing','approve')` -> service delegation | WIRED | Route imports and calls the function directly with `(id, actions, actor)`; not a stub — errors from the service propagate to typed HTTP status mapping. | +| `remediate/route.ts` | `remediateApprovedActions` | `requirePermission('phishing','remediate')` -> service delegation | WIRED | Same pattern confirmed. | +| `mark-false-positive/route.ts` | `markCampaignFalsePositive` | `requirePermission('phishing','approve')` -> service delegation | WIRED | Same pattern confirmed. | +| `classify/route.ts` | `writeAuditEvent` | post-classify audit write | WIRED | Call is inside the existing try block after `classifyCampaign(id)` succeeds. | +| routes | `middleware.ts` | auth gate | WIRED | `/api/phishing/*` is NOT in `middleware.ts`'s public-route allowlist (confirmed via grep — zero matches), so unauthenticated requests are redirected/blocked before reaching route-level `requirePermission`. | + +### Behavioral Spot-Checks + +| Behavior | Command | Result | Status | +|----------|---------|--------|--------| +| Service unit tests (11 assertions across approve/remediate/mark-fp/audit-writer) | `npx vitest run lib/services/phishing-audit.test.ts lib/services/remediation-service.test.ts` | 2 files, 11 tests, all passed | PASS | +| Type check | `npx tsc --noEmit --pretty` | clean, no output | PASS | +| Full test suite regression | `npx vitest run` | 369/371 passing; 2 failures isolated to `lib/services/analyzer/itglue-search.test.ts` | PASS (pre-existing, out-of-scope failure confirmed — last touched by commit `8f8b5ab`, an unrelated earlier commit, not part of this phase's diff) | +| No auto-execution of remediation on classify | `grep -n "remediation_actions" lib/services/campaign-classifier.ts` | no matches | PASS | +| Single audit insert path | `grep -rn "INSERT INTO audit_events"` across `lib/` and `app/` | only in `phishing-audit.ts` | PASS | + +### Requirements Coverage + +| Requirement | Source Plan | Description | Status | Evidence | +|-------------|------------|-------------|--------|----------| +| REMED-01 | 20-01 | Proposed-only, never auto-executed | SATISFIED | See Truth 1 | +| REMED-02 | 20-01, 20-02 | Approve via API, records approver/timestamp/params | SATISFIED | See Truth 2 | +| REMED-03 | 20-01, 20-02 | Remediate only proceeds for approved, non-destructive, never silent success | SATISFIED | See Truth 3 | +| REMED-04 | 20-01 | Idempotent re-run | SATISFIED | See Truth 4 | +| REMED-05 | 20-01, 20-02 | Mark false positive via API | SATISFIED | See Truth 5 | +| REMED-06 | 20-01, 20-02 | Every state-changing action audited | SATISFIED | See Truth 6 | + +Note: `.planning/REQUIREMENTS.md` still lists REMED-01..06 as "Pending" in its coverage table (lines 198-203) and unchecked (`[ ]`) in the requirement list — this is a tracking-document staleness issue, not a code gap. Recommend updating REQUIREMENTS.md status table as a follow-up, but it does not block phase goal achievement since the underlying code is verified. + +### Anti-Patterns Found + +None found in the phase's modified/created files. No `TODO`/`FIXME`/`HACK`/`PLACEHOLDER` markers, no empty handlers, no hardcoded empty returns feeding into rendering, no `not_implemented` code paths (only descriptive prose in comments, explicitly called out in 20-01-SUMMARY.md as a deliberate wording fix to avoid tripping the D-01 grep check). + +### Human Verification Required + +None. This phase is service + API layer only (no new UI in this phase — Phase 22 covers the approval UI). All behaviors are verifiable via code inspection, unit tests, and static grep checks. + +### Gaps Summary + +No gaps found. All six REMED requirements are backed by real, non-stub implementations: +- The service layer (`phishing-audit.ts`, `remediation-service.ts`) performs real parameterized SQL, real transactional atomicity between state writes and audit writes, and real validation logic (not pass-through stubs). +- The route layer enforces real permission checks (`requirePermission` calls `hasPermission` against actual role definitions, returning 403 on failure — not just declaring permissions without enforcing them). +- Idempotency and the D-04 conflict guard are proven by unit tests that assert on actual SQL call counts and audit-write counts, not just "does not throw." +- Actor identity is derived from the server session in all three new routes and in the classify route edit — never from request body, closing the spoofing/repudiation threat noted in the phase's own threat model. + +--- + +_Verified: 2026-07-16T11:00:00Z_ +_Verifier: Claude (gsd-verifier)_ diff --git a/.planning/phases/21-autotask-triage-note/21-PATTERNS.md b/.planning/phases/21-autotask-triage-note/21-PATTERNS.md new file mode 100644 index 0000000..795a6bc --- /dev/null +++ b/.planning/phases/21-autotask-triage-note/21-PATTERNS.md @@ -0,0 +1,364 @@ +# Phase 21: Autotask Triage Note - Pattern Map + +**Mapped:** 2026-07-16 +**Files analyzed:** 2 new (route + service), 1 optional (service test) +**Analogs found:** 2 / 2 (both exact/near-exact structural matches) + +## File Classification + +| New/Modified File | Role | Data Flow | Closest Analog | Match Quality | +|-------------------|------|-----------|----------------|---------------| +| `app/api/phishing/campaigns/[id]/triage-note/route.ts` (new) | controller (route) | request-response | `app/api/phishing/campaigns/[id]/classify/route.ts` | exact | +| `lib/services/triage-note-service.ts` (new — planner may name differently) | service | CRUD (read-many) + file/external-write (Autotask `TicketNotes` POST per linked ticket) | `lib/services/campaign-classifier.ts` (evidence gathering half) + `lib/services/workflow-engine.ts` `runAiTroubleshooting` (Autotask write half) | role-match (composite — no single existing file does both halves) | +| `lib/services/triage-note-service.test.ts` (optional, if planner follows sibling-test convention) | test | n/a | `lib/services/remediation-service.test.ts` / `lib/services/campaign-classifier.test.ts` | role-match | + +## Pattern Assignments + +### `app/api/phishing/campaigns/[id]/triage-note/route.ts` (controller, request-response) + +**Analog:** `app/api/phishing/campaigns/[id]/classify/route.ts` (full file read — 67 lines) + +This is a near-identical structural twin. Copy the whole shape: UUID guard, +`requirePermission`, campaign-exists pre-check, service delegation, try/catch +with typed error branches, `console.error` with a `[PHISHING-*]` tag. + +**Imports pattern** (lines 12-16): +```typescript +import { NextRequest, NextResponse } from 'next/server'; +import { requirePermission } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; +import { classifyCampaign } from '@/lib/services/campaign-classifier'; +import { writeAuditEvent } from '@/lib/services/phishing-audit'; +``` +For the new route, swap the service import for the new triage-note service +export (e.g. `generateAndPostTriageNote`) — `writeAuditEvent` is optional here +(no explicit audit event is required by CONTEXT.md D-01..D-06 for this phase; +if the planner wants one, `remediation-service.ts`'s in-transaction audit +pattern is the reference — see Shared Patterns below). + +**UUID guard + permission gate** (lines 18, 20-32): +```typescript +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { session, error } = await requirePermission('phishing', 'analyze'); + if (error) return error; + + const { id } = await params; + // V5: validate UUID shape before querying — a malformed id would otherwise + // surface as an unhandled Postgres error -> uncaught 500. + if (!UUID_RE.test(id)) { + return NextResponse.json({ error: 'Invalid campaign id' }, { status: 400 }); + } +``` +Per CONTEXT.md's deferred discretion note, `'analyze'` (not `'approve'`) is +the recommended permission tier — matches `classify`'s tier since this is +informational, not a destructive state change. + +**Campaign-exists pre-check + service delegation + response** (lines 34-43, 58): +```typescript + try { + const campaignRes = await postgresClient.query<{ id: string }>( + `SELECT id FROM campaigns WHERE id = $1`, + [id] + ); + if (!campaignRes.rows[0]) { + return NextResponse.json({ error: 'Campaign not found' }, { status: 404 }); + } + + const result = await classifyCampaign(id); // -> generateAndPostTriageNote(id) + + return NextResponse.json(result); +``` +D-06's response shape (note text + per-ticket `posted`/error status list) +should be returned directly as the service's return value — no reshaping +needed in the route, matching how `classify`/`approve`/`remediate` all just +`NextResponse.json(result)` the service's return type verbatim. + +**Error handling pattern** (lines 59-65 — the ONLY error branch this route +needs, since D-05 says individual write failures are captured *inside* the +service's return value, not thrown): +```typescript + } catch (err) { + console.error('[PHISHING-CLASSIFY] Failed to classify campaign', id, err); + return NextResponse.json( + { error: 'Failed to classify campaign', message: err instanceof Error ? err.message : 'Unknown error' }, + { status: 500 } + ); + } +} +``` +Rename the log tag (e.g. `[PHISHING-TRIAGE-NOTE]`) and message. This catch +block should only ever fire for a whole-request failure (e.g. campaign +evidence-gathering itself throws) — NOT for a single ticket's Autotask write +failing, which D-05/D-06 require to be caught per-ticket inside the service +and reported in the 200 response body instead. + +**Optional: typed-error branches** if the service throws domain errors (see +`approve/route.ts` lines 63-69 for the pattern, not strictly needed here since +this phase has no validation-conflict states like approve/remediate do): +```typescript + if (err instanceof RemediationValidationError) { + return NextResponse.json({ error: err.message }, { status: 400 }); + } +``` + +--- + +### `lib/services/triage-note-service.ts` (service, CRUD-read + external-write) + +No single existing file does both halves this service needs, so it composes +two analogs: **evidence gathering** (read side, copy shape from +`campaign-classifier.ts`'s `gatherCampaignEvidence`) and **Autotask note +write** (write side, copy verbatim from `workflow-engine.ts`'s +`runAiTroubleshooting`). + +**Imports pattern** — composite of `campaign-classifier.ts` (lines 17-19) and +the Autotask factory used across `workflow-engine.ts`: +```typescript +import { postgresClient } from './postgres-client'; +import { getBlastRadius, type BlastRadiusResult } from './mimecast-blast-radius'; +import { getAutotaskClient } from './autotask-factory'; +import type { TicketNote } from '@/lib/types/autotask'; +``` + +**Read-side pattern — bulk-fetch linked reports, then classification + +remediation state** (`campaign-classifier.ts` lines 270-296, adapted; also see +`app/api/phishing/campaigns/[id]/route.ts` lines 84-96 for the same +`reports WHERE campaign_id = $1 ORDER BY created_at ASC` bulk-fetch shape used +a third time in this codebase): +```typescript +const reportsRes = await postgresClient.query( + `SELECT r.id::text AS id, r.ticket_id::text AS ticket_id, r.ticket_number, + r.title, r.created_at::text AS created_at, + c.email_address AS requester_email + FROM reports r + LEFT JOIN contacts c ON c.id = r.requester_contact_id + WHERE r.campaign_id = $1 + ORDER BY r.created_at ASC`, + [campaignId] +); +``` + +**Most-recent classification** (`remediation-service.ts` lines 89-96 — same +`ORDER BY created_at DESC LIMIT 1` idiom used for "current" state per D-04): +```typescript +const classificationRes = await client.query( + `SELECT recommended_actions + FROM classifications + WHERE campaign_id = $1 + ORDER BY created_at DESC + LIMIT 1`, + [campaignId] +); +``` +For the triage note, select the full row (`verdict, confidence, summary, +reasons, recommended_actions, requires_approval, created_at`), not just +`recommended_actions` — D-04 requires verdict/confidence/summary/reasons in +the note. + +**Current remediation_actions state** (D-04 — "proposed only if no operator +has acted, else approved/completed rows") — same table/columns +`remediation-service.ts` already reads/writes (lines 133-148, 175-179): +```typescript +const remediationRes = await postgresClient.query( + `SELECT id::text, action_type, status, approved_by, approved_at::text + FROM remediation_actions + WHERE campaign_id = $1 + ORDER BY created_at ASC`, + [campaignId] +); +``` + +**Blast radius — reuse the exact `getBlastRadius()` call shape** from +`campaign-classifier.ts` lines 332-351 (D-04/Claude's-Discretion: planner may +call fresh or reuse Phase 19's persisted `reasons` — either way this is the +call signature to copy if calling fresh): +```typescript +const blastRadius = await getBlastRadius({ + sender: senderIndicator?.value ?? primaryMessage?.from.email ?? '', + recipient: primaryReport.requesterEmail ?? '', + subject: primaryMessage?.subject ?? primaryReport.title ?? '', + dateWindow: { + start: new Date(createdAt.getTime() - 24 * 60 * 60 * 1000), + end: new Date(createdAt.getTime() + 24 * 60 * 60 * 1000), + }, +}); +// BlastRadiusResult is a discriminated union — status: 'ok' | 'unavailable'. +// D-04 requires an explicit "unavailable" string in the note when this +// branch is hit, never a silent omission. +``` + +**Write-side pattern — one `createEntity('TicketNotes', ...)` call per linked +ticket, copied verbatim from `workflow-engine.ts` lines 581-589**: +```typescript +const client = getAutotaskClient(); +await client.createEntity('TicketNotes', { + ticketID: ticket.id, // -> report.ticketId for each linked report (D-01) + title: 'Troubleshooting Steps (Auto-Generated)', // -> e.g. 'Phishing Triage Summary' + description: steps, // -> the generated sanitized note text + noteType: 1, // Internal + publish: 1, +}); +``` +`TicketNote` interface for reference (`lib/types/autotask.ts` lines 185-196): +```typescript +export interface TicketNote { + id: number; + ticketID: number; + title?: string; + description?: string; + noteType?: number; + publish?: number; + creatorResourceID?: number; + creatorType?: number; + lastActivityDate?: string; + createDateTime?: string; +} +``` +`createEntity` generic signature (`lib/services/autotask-client.ts` lines +175-189) — throws `Error('Failed to create entity')` if Autotask's response +has no `item`, and lets network/HTTP errors from `makeApiCall` propagate +uncaught. **This is exactly the failure mode D-05 requires the service to +catch per-ticket** — wrap each `createEntity` call in its own try/catch inside +a loop over linked tickets, not one try/catch around the whole loop: +```typescript +const ticketResults: Array<{ ticketId: string; posted: boolean; error?: string }> = []; +for (const report of reports) { + try { + await client.createEntity('TicketNotes', { + ticketID: Number(report.ticketId), + title: 'Phishing Triage Summary', + description: noteText, + noteType: 1, + publish: 1, + }); + ticketResults.push({ ticketId: report.ticketId, posted: true }); + } catch (err) { + console.error('[TRIAGE-NOTE] Failed to post note to ticket', report.ticketId, err); + ticketResults.push({ + ticketId: report.ticketId, + posted: false, + error: err instanceof Error ? err.message : 'Unknown error', + }); + } +} +``` + +**Sanitization precedent** — `lib/services/analyzer/itglue-redact.ts` (full +file, 65 lines) is the spirit-reference named in CONTEXT.md, though it +redacts by KEY NAME across an arbitrary object tree (IT Glue documents), which +doesn't map directly onto this phase's need (truncating URL query strings +inside plain prose text). Two concrete things to actually copy: +1. The **module-level "why" comment convention** — state plainly what must + never leak and why, mirroring lines 1-16 of `itglue-redact.ts`. +2. The **exported-for-tests + pure-function** shape — a small + `sanitizeIndicatorValue(value: string, type: string): string` (or similar) + function, unit-testable in isolation, same as `redact()`/`isSensitiveKey()` + are exported standalone in `itglue-redact.ts` lines 26-28 and 62-64. For + URL truncation specifically there is no existing analog in this codebase — + this is genuinely new logic (strip query string via `new URL(value).origin + + new URL(value).pathname`, wrapped in try/catch for malformed URLs). + +**No-op / synthesized-value pattern for missing evidence** — copy +`mimecast-blast-radius.ts`'s discriminated union (`status: 'ok' | 'unavailable'`, +never a thrown error for a missing/misconfigured integration) as the model for +how the note text should render "blast radius data unavailable" rather than +omitting the section — same spirit as `campaign-classifier.ts` line 350's +`{ status: 'unavailable', reason: 'not_configured' }` synthesis when there are +no linked reports at all. + +--- + +### `lib/services/triage-note-service.test.ts` (test, optional) + +**Analog:** `lib/services/remediation-service.test.ts` and +`lib/services/campaign-classifier.test.ts` (not read in full — file names +only, per early-stopping guidance; both are existing Vitest suites under +`lib/services/` that test service functions directly against a real/fixture +Postgres, following the project's stated test coverage: `lib/services/**` is +covered). Structure to follow: mock or seed `campaigns`/`reports`/ +`classifications`/`remediation_actions` rows, mock `getAutotaskClient()` (or +the whole `autotask-factory` module) to assert `createEntity` was called once +per linked ticket with the expected `ticketID`/`description`, and assert the +per-ticket failure path (D-05/D-06) when a mocked `createEntity` rejects for +one of several tickets. + +## Shared Patterns + +### Auth/Permission gate +**Source:** `lib/auth-utils.ts` lines 51-71 (`requirePermission`), used +identically by `classify/route.ts` line 24, `approve/route.ts` line 28, +`remediate/route.ts` line 27, `route.ts` (GET) line 62. +**Apply to:** the new triage-note route. +```typescript +const { session, error } = await requirePermission('phishing', 'analyze'); +if (error) return error; +``` +`lib/permissions.ts` line 33/50/64/78 confirms `'analyze'` is already granted +to admin/super-admin/user roles (only the read-only-ish role at line 78 lacks +it) — no new permission statement needed. + +### UUID param validation +**Source:** identical `UUID_RE` regex + early-400 pattern in all four existing +`campaigns/[id]/*` routes (`classify`, `approve`, `remediate`, base `route.ts`). +**Apply to:** the new triage-note route — copy the exact regex, don't +re-derive it. + +### Campaign-exists pre-check before service delegation +**Source:** `classify/route.ts` lines 34-41, `approve/route.ts` lines 52-59, +`remediate/route.ts` lines 40-46 — all three query `SELECT id FROM campaigns +WHERE id = $1` and return 404 before calling their service function. +**Apply to:** the new triage-note route, same shape. + +### Error response shape +**Source:** every phishing route's catch block: +`NextResponse.json({ error: '...', message: err instanceof Error ? err.message : 'Unknown error' }, { status: 500 })` +with a `console.error('[PHISHING-] ...')` line immediately before. +**Apply to:** the new route's outer catch (whole-request failures only — see +D-05 note above about per-ticket failures NOT using this branch). + +### Safe Autotask ticket-note write +**Source:** `lib/services/workflow-engine.ts` lines 581-589 +(`runAiTroubleshooting`), backed by `lib/services/autotask-client.ts` +`createEntity` (lines 175-189) and `lib/services/autotask-factory.ts` +`getAutotaskClient()`. +**Apply to:** the new service's write loop — `noteType: 1` (Internal), +`publish: 1` (All Autotask Users, still non-portal per +`AUTOTASK_API_GUIDE.md` line 365) are the existing codebase's only precedent +values; reuse them unless the planner has a specific reason to pick +`publish: 2` (Internal Users Only — even more restrictive, also non-portal). + +### Audit trail (optional — not required by CONTEXT.md for this phase) +**Source:** `lib/services/phishing-audit.ts` (full file, 55 lines) — +`writeAuditEvent({ campaignId, actor, eventType, payload }, client?)`. Used by +every state-*changing* action (classify/approve/remediate/false-positive). +This phase is read+external-write, not a Postgres state change, so an audit +row is NOT strictly required by any D-0x decision — CONTEXT.md's Deferred +Ideas section explicitly puts "persisting sent-note history" out of scope. +If the planner still wants a lightweight audit trail of *when* a triage note +was requested (not full content), this is the write shape to reuse; `client` +param is optional so it can be called standalone (no transaction needed since +there's no corresponding state row to keep atomic with). + +## No Analog Found + +| File | Role | Data Flow | Reason | +|------|------|-----------|--------| +| URL/text sanitization helper (e.g. `lib/services/triage-note-sanitize.ts`, if split out) | utility | transform | No existing codebase function truncates URL query strings or formats human-readable prose from structured evidence — `itglue-redact.ts` redacts by object key name (a different technique for a different data shape); this is genuinely new logic per CONTEXT.md's "Claude's Discretion" section. | +| Note-text template/formatter | utility | transform | No existing "build human-readable prose from campaign+classification+remediation rows" function exists anywhere in the codebase — closest precedent is `campaign-classifier.ts`'s one-line `summary` string (line 487), which is far shorter than what D-04 requires here. | + +## Metadata + +**Analog search scope:** `app/api/phishing/**`, `lib/services/campaign-classifier.ts`, +`lib/services/remediation-service.ts`, `lib/services/campaign-grouping-service.ts`, +`lib/services/phishing-audit.ts`, `lib/services/workflow-engine.ts`, +`lib/services/autotask-client.ts`, `lib/services/autotask-factory.ts`, +`lib/services/mimecast-blast-radius.ts`, `lib/services/analyzer/itglue-search.ts`, +`lib/services/analyzer/itglue-redact.ts`, `lib/permissions.ts`, `lib/auth-utils.ts`, +`lib/types/autotask.ts`, `migrations/097_phishing_triage_schema.sql`. +**Files scanned:** 15 +**Pattern extraction date:** 2026-07-16 diff --git a/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/.gitkeep b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/api/dashboard/trends/route.ts b/app/api/dashboard/trends/route.ts index 9a53b21..834d93d 100644 --- a/app/api/dashboard/trends/route.ts +++ b/app/api/dashboard/trends/route.ts @@ -5,7 +5,8 @@ * volumeByDay — last 30 days, ticket creation count per day * resolutionByDay — last 30 days, mean resolution hours per day completed * queueHeatmap — open tickets grouped by (queue, priority) - * activeEngineers — top engineers today by hours logged + * activeEngineers — working engineers today (top N by hours), each with their per-ticket time entries + * ptoEngineers — engineers whose only time today is on PTO/Vacation allocation codes * * All queries run in parallel. ~50 ms total against a warm DB. */ @@ -18,6 +19,10 @@ import { getUserTimezone } from '@/lib/services/user-timezone'; const TREND_DAYS = 30; const TOP_QUEUES = 10; const TOP_ENGINEERS = 8; +// Autotask allocation codes treated as PTO/Vacation/time-off. +// These are the internal codes used at Wulf for non-billable time-off entries +// (vacation, personal day, etc.) — surfaced separately from working hours. +const PTO_ALLOCATION_CODE_IDS = [91206, 91207, 91209]; export async function GET() { const { session, error } = await requireAuth(); @@ -63,6 +68,7 @@ export async function GET() { [tz], ), // queueHeatmap: open-only counts (no day-boundary math) — tz does not apply. + // Filters out queues the calling user has hidden via /api/me/queue-preferences. postgresClient.query<{ queue_id: number | null; queue_label: string | null; @@ -77,29 +83,81 @@ export async function GET() { LEFT JOIN queues q ON q.value = t.queue_id WHERE t.completed_date IS NULL AND (t.is_deleted = false OR t.is_deleted IS NULL) + AND (t.queue_id IS NULL OR t.queue_id NOT IN ( + SELECT queue_id FROM user_queue_preferences WHERE user_id = $1 + )) GROUP BY t.queue_id, q.label, t.priority ORDER BY COUNT(*) DESC`, + [session!.user.id], ), postgresClient.query<{ resource_id: string; resource_name: string; hours: string; tickets_touched: string; + ticket_id: string | null; + ticket_number: string | null; + ticket_title: string | null; + ticket_description: string | null; + ticket_status_label: string | null; + ticket_hours: string | null; + is_pto: boolean; + pto_note: string | null; }>( - `SELECT te.resource_id::text, + `WITH today_entries AS ( + SELECT te.resource_id, + te.ticket_id, + te.hours_worked, + te.allocation_code_id, + te.notes, + te.title + FROM time_entries te + WHERE te.entry_date::date = (NOW() AT TIME ZONE $1)::date + AND te.hours_worked > 0 + ), + engineer_totals AS ( + SELECT resource_id, + SUM(hours_worked) AS hours, + COUNT(DISTINCT ticket_id) FILTER (WHERE ticket_id IS NOT NULL) AS tickets_touched, + BOOL_OR(allocation_code_id = ANY($2::int[])) AS has_pto, + BOOL_OR(allocation_code_id IS NULL OR NOT (allocation_code_id = ANY($2::int[]))) AS has_work, + MAX(CASE WHEN allocation_code_id = ANY($2::int[]) + THEN NULLIF(COALESCE(notes, title), '') END) AS pto_note + FROM today_entries + GROUP BY resource_id + ), + ticket_totals AS ( + SELECT te.resource_id, + te.ticket_id, + SUM(te.hours_worked) AS ticket_hours + FROM today_entries te + WHERE te.ticket_id IS NOT NULL + GROUP BY te.resource_id, te.ticket_id + ) + SELECT et.resource_id::text, COALESCE(NULLIF(TRIM(r.first_name || ' ' || COALESCE(r.last_name, '')), ''), r.email, - 'Resource ' || te.resource_id) AS resource_name, - SUM(te.hours_worked)::text AS hours, - COUNT(DISTINCT te.ticket_id)::text AS tickets_touched - FROM time_entries te - LEFT JOIN resources r ON r.id = te.resource_id - WHERE ((te.entry_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = (NOW() AT TIME ZONE $1)::date - AND te.hours_worked > 0 - GROUP BY te.resource_id, r.first_name, r.last_name, r.email - ORDER BY SUM(te.hours_worked) DESC - LIMIT ${TOP_ENGINEERS}`, - [tz], + 'Resource ' || et.resource_id) AS resource_name, + et.hours::text AS hours, + et.tickets_touched::text AS tickets_touched, + tt.ticket_id::text AS ticket_id, + t.ticket_number, + t.title AS ticket_title, + t.description AS ticket_description, + s.label AS ticket_status_label, + tt.ticket_hours::text AS ticket_hours, + (et.has_pto AND NOT et.has_work) AS is_pto, + et.pto_note + FROM engineer_totals et + LEFT JOIN resources r ON r.id = et.resource_id + LEFT JOIN ticket_totals tt ON tt.resource_id = et.resource_id + LEFT JOIN tickets t ON t.id = tt.ticket_id + LEFT JOIN statuses s ON s.value = t.status + ORDER BY (et.has_pto AND NOT et.has_work) ASC, + et.hours DESC, + et.resource_id, + tt.ticket_hours DESC NULLS LAST`, + [tz, PTO_ALLOCATION_CODE_IDS], ), ]); @@ -129,6 +187,61 @@ export async function GET() { return { queueId: q.id, queueLabel: q.label, total: q.total, byPriority: cells }; }); + // Fold the joined engineer/ticket rows into per-engineer records. + type Ticket = { + id: string; + ticketNumber: string | null; + title: string | null; + description: string | null; + statusLabel: string | null; + hours: number; + }; + type Engineer = { + resourceId: string; + name: string; + hours: number; + ticketsTouched: number; + tickets: Ticket[]; + isPto: boolean; + ptoNote: string | null; + }; + const engineerById = new Map(); + for (const row of engineersRes.rows) { + let eng = engineerById.get(row.resource_id); + if (!eng) { + eng = { + resourceId: row.resource_id, + name: row.resource_name, + hours: Math.round(parseFloat(row.hours) * 10) / 10, + ticketsTouched: parseInt(row.tickets_touched, 10), + tickets: [], + isPto: row.is_pto, + ptoNote: row.pto_note, + }; + engineerById.set(row.resource_id, eng); + } + if (row.ticket_id) { + eng.tickets.push({ + id: row.ticket_id, + ticketNumber: row.ticket_number, + title: row.ticket_title, + description: row.ticket_description, + statusLabel: row.ticket_status_label, + hours: row.ticket_hours == null + ? 0 + : Math.round(parseFloat(row.ticket_hours) * 10) / 10, + }); + } + } + const allEngineers = [...engineerById.values()]; + const activeEngineers = allEngineers + .filter((e) => !e.isPto) + .sort((a, b) => b.hours - a.hours) + .slice(0, TOP_ENGINEERS); + const ptoEngineers = allEngineers + .filter((e) => e.isPto) + .sort((a, b) => a.name.localeCompare(b.name)); + return NextResponse.json({ volumeByDay: volumeRes.rows.map((r) => ({ date: r.d, @@ -139,11 +252,7 @@ export async function GET() { avgHours: r.avg_hours == null ? null : Math.round(parseFloat(r.avg_hours) * 10) / 10, })), queueHeatmap: heatmap, - activeEngineers: engineersRes.rows.map((r) => ({ - resourceId: r.resource_id, - name: r.resource_name, - hours: Math.round(parseFloat(r.hours) * 10) / 10, - ticketsTouched: parseInt(r.tickets_touched, 10), - })), + activeEngineers, + ptoEngineers, }); } diff --git a/app/api/me/queue-preferences/route.ts b/app/api/me/queue-preferences/route.ts new file mode 100644 index 0000000..0022c0c --- /dev/null +++ b/app/api/me/queue-preferences/route.ts @@ -0,0 +1,101 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import { postgresClient } from '@/lib/services/postgres-client'; + +// GET /api/me/queue-preferences +// -> { queues: [{ id, label, hidden }], hiddenIds: number[] } +// Returns every active queue with a `hidden` flag for the calling user, +// plus the bare list of hidden queue IDs (for clients that only need that). +// +// PUT /api/me/queue-preferences +// body { hiddenIds: number[] } -> { hiddenIds: number[] } +// Replaces the user's hidden-queue set atomically (full set semantics). +// +// Per-user only — writes target session.user.id, never an id from the body. + +export async function GET(): Promise { + const { session, error } = await requireAuth(); + if (error) return error; + + try { + const result = await postgresClient.query<{ + value: number; + label: string; + hidden: boolean; + }>( + `SELECT q.value, q.label, + (p.queue_id IS NOT NULL) AS hidden + FROM queues q + LEFT JOIN user_queue_preferences p + ON p.queue_id = q.value AND p.user_id = $1 + WHERE q.is_active = true + AND (q.is_deleted = false OR q.is_deleted IS NULL) + ORDER BY q.label`, + [session!.user.id], + ); + const queues = result.rows.map((r) => ({ + id: r.value, + label: r.label, + hidden: r.hidden, + })); + const hiddenIds = queues.filter((q) => q.hidden).map((q) => q.id); + return NextResponse.json({ queues, hiddenIds }); + } catch (e) { + console.error('GET /api/me/queue-preferences failed:', e); + return NextResponse.json( + { error: 'Failed to read queue preferences', message: e instanceof Error ? e.message : 'unknown' }, + { status: 500 }, + ); + } +} + +export async function PUT(request: NextRequest): Promise { + const { session, error } = await requireAuth(); + if (error) return error; + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { error: 'Invalid JSON', message: 'Request body must be JSON' }, + { status: 400 }, + ); + } + + const raw = + body && typeof body === 'object' && 'hiddenIds' in body + ? (body as { hiddenIds: unknown }).hiddenIds + : undefined; + + if (!Array.isArray(raw) || !raw.every((v) => Number.isInteger(v))) { + return NextResponse.json( + { error: 'Invalid input', message: 'hiddenIds must be an array of integers' }, + { status: 400 }, + ); + } + const hiddenIds = [...new Set(raw as number[])]; + + const userId = session!.user.id; + + try { + await postgresClient.transaction(async (client) => { + await client.query('DELETE FROM user_queue_preferences WHERE user_id = $1', [userId]); + if (hiddenIds.length > 0) { + const placeholders = hiddenIds.map((_, i) => `($1, $${i + 2})`).join(', '); + await client.query( + `INSERT INTO user_queue_preferences (user_id, queue_id) VALUES ${placeholders} + ON CONFLICT (user_id, queue_id) DO NOTHING`, + [userId, ...hiddenIds], + ); + } + }); + return NextResponse.json({ hiddenIds }); + } catch (e) { + console.error('PUT /api/me/queue-preferences failed:', e); + return NextResponse.json( + { error: 'Failed to update queue preferences', message: e instanceof Error ? e.message : 'unknown' }, + { status: 500 }, + ); + } +} diff --git a/app/api/mobile/engagement/summary/route.ts b/app/api/mobile/engagement/summary/route.ts index 6809751..a5dd95b 100644 --- a/app/api/mobile/engagement/summary/route.ts +++ b/app/api/mobile/engagement/summary/route.ts @@ -131,7 +131,7 @@ export async function GET(request: NextRequest): Promise { FROM time_entries te JOIN resources r ON r.id = te.resource_id AND (r.is_deleted = false OR r.is_deleted IS NULL) JOIN graph_users gu ON LOWER(gu.email) = LOWER(r.email) - WHERE (te.entry_date AT TIME ZONE 'UTC' AT TIME ZONE $1) >= (NOW() AT TIME ZONE $1) - INTERVAL '${interval}' + WHERE te.entry_date >= (NOW() AT TIME ZONE $1)::date - INTERVAL '${interval}' AND (te.is_deleted = false OR te.is_deleted IS NULL) AND gu.account_enabled = true AND LOWER(gu.email) LIKE '%@wulfconsulting.%' diff --git a/app/api/mobile/engagement/trend/route.ts b/app/api/mobile/engagement/trend/route.ts index afcf32c..88dff18 100644 --- a/app/api/mobile/engagement/trend/route.ts +++ b/app/api/mobile/engagement/trend/route.ts @@ -56,18 +56,18 @@ export async function GET(request: NextRequest): Promise { )::date AS day ), daily_hours AS ( - SELECT (te.entry_date AT TIME ZONE 'UTC' AT TIME ZONE $1)::date AS day, COALESCE(SUM(te.hours_worked), 0) AS hours + SELECT te.entry_date::date AS day, COALESCE(SUM(te.hours_worked), 0) AS hours FROM time_entries te JOIN resources r ON r.id = te.resource_id AND (r.is_deleted = false OR r.is_deleted IS NULL) JOIN graph_users gu ON LOWER(gu.email) = LOWER(r.email) - WHERE (te.entry_date AT TIME ZONE 'UTC' AT TIME ZONE $1)::date >= ((NOW() AT TIME ZONE $1) - INTERVAL '${days - 1} days')::date - AND (te.entry_date AT TIME ZONE 'UTC' AT TIME ZONE $1)::date <= (NOW() AT TIME ZONE $1)::date + WHERE te.entry_date::date >= ((NOW() AT TIME ZONE $1) - INTERVAL '${days - 1} days')::date + AND te.entry_date::date <= (NOW() AT TIME ZONE $1)::date AND (te.is_deleted = false OR te.is_deleted IS NULL) AND gu.account_enabled = true AND LOWER(gu.email) LIKE '%@wulfconsulting.%' AND LOWER(gu.email) NOT LIKE '%#ext#%' - GROUP BY (te.entry_date AT TIME ZONE 'UTC' AT TIME ZONE $1)::date + GROUP BY te.entry_date::date ) SELECT to_char(ds.day, 'YYYY-MM-DD') AS date, COALESCE(dh.hours, 0)::numeric AS hours diff --git a/app/api/mobile/finance/route.ts b/app/api/mobile/finance/route.ts index 17bf669..66083b4 100644 --- a/app/api/mobile/finance/route.ts +++ b/app/api/mobile/finance/route.ts @@ -13,11 +13,11 @@ export async function GET() { if (error) return error; const tz = getUserTimezone(session); - const [summary, aging, topCustomers, overdueInvoices, recentPayments, monthlyRevenue] = await Promise.all([ + const [summary, aging, topCustomers, overdueInvoices, recentPayments, monthlyRevenue, credits] = await Promise.all([ postgresClient.query( ` SELECT - SUM(balance) FILTER (WHERE status IN ('Open','Overdue')) as total_ar, + SUM(balance) FILTER (WHERE status IN ('Open','Overdue')) as total_ar_gross, COUNT(*) FILTER (WHERE status IN ('Open','Overdue')) as total_ar_count, SUM(balance) FILTER (WHERE status = 'Open') as current_balance, COUNT(*) FILTER (WHERE status = 'Open') as current_count, @@ -26,6 +26,7 @@ export async function GET() { SUM(total_amt) FILTER (WHERE status = 'Paid' AND (txn_date AT TIME ZONE 'UTC' AT TIME ZONE $1) >= DATE_TRUNC('month', NOW() AT TIME ZONE $1)) as paid_mtd, SUM(total_amt) FILTER (WHERE status = 'Paid' AND (txn_date AT TIME ZONE 'UTC' AT TIME ZONE $1) >= DATE_TRUNC('year', NOW() AT TIME ZONE $1)) as paid_ytd FROM qbo_invoices + WHERE is_deleted = false `, [tz], ), @@ -41,22 +42,43 @@ export async function GET() { SUM(balance) FILTER (WHERE status = 'Overdue' AND due_date < (NOW() AT TIME ZONE $1)::date - 60) as days_60_plus, COUNT(*) FILTER (WHERE status = 'Overdue' AND due_date < (NOW() AT TIME ZONE $1)::date - 60) as cnt_60_plus FROM qbo_invoices + WHERE is_deleted = false `, [tz], ), + // Top customers — net out any unapplied payment credits the customer is + // sitting on, so the number matches QBO's customer A/R view. postgresClient.query(` - SELECT customer_ref_name, SUM(balance) as balance, COUNT(*) as invoice_count - FROM qbo_invoices - WHERE status IN ('Open','Overdue') - GROUP BY customer_ref_name - ORDER BY balance DESC LIMIT 8 + WITH inv AS ( + SELECT customer_ref_id, customer_ref_name, + SUM(balance) AS gross_balance, + COUNT(*) AS invoice_count + FROM qbo_invoices + WHERE is_deleted = false + AND status IN ('Open','Overdue') + GROUP BY customer_ref_id, customer_ref_name + ), + cred AS ( + SELECT customer_ref_id, SUM(unapplied_amt) AS unapplied + FROM qbo_payments + WHERE unapplied_amt > 0 + GROUP BY customer_ref_id + ) + SELECT inv.customer_ref_name, + GREATEST(inv.gross_balance - COALESCE(cred.unapplied, 0), 0) AS balance, + inv.invoice_count + FROM inv + LEFT JOIN cred ON cred.customer_ref_id = inv.customer_ref_id + ORDER BY balance DESC + LIMIT 8 `), postgresClient.query( ` SELECT id, doc_number, txn_date, due_date, customer_ref_name, total_amt, balance, status, (NOW() AT TIME ZONE $1)::date - due_date::date as days_overdue FROM qbo_invoices - WHERE status IN ('Open','Overdue') + WHERE is_deleted = false + AND status IN ('Open','Overdue') ORDER BY status DESC, balance DESC LIMIT 30 `, [tz], @@ -72,23 +94,39 @@ export async function GET() { SELECT DATE_TRUNC('month', txn_date) as month, SUM(total_amt) as revenue, COUNT(*) as invoice_count FROM qbo_invoices - WHERE status = 'Paid' AND txn_date >= NOW() - INTERVAL '12 months' + WHERE is_deleted = false + AND status = 'Paid' + AND txn_date >= NOW() - INTERVAL '12 months' GROUP BY 1 ORDER BY 1 ASC `), + // Unapplied customer credits — payments not yet linked to an invoice. + // QBO nets these against A/R in its customer balance reports. + postgresClient.query(` + SELECT COALESCE(SUM(unapplied_amt), 0) AS total_unapplied + FROM qbo_payments + WHERE unapplied_amt > 0 + `), ]); const s = summary.rows[0]; const a = aging.rows[0]; + const unappliedCredits = parseFloat(credits.rows[0]?.total_unapplied ?? 0); + const grossAr = parseFloat(s.total_ar_gross ?? 0); + // QBO's A/R reports net unapplied customer credits against the gross + // invoice balance. Mirror that so the dashboard headline matches QBO. + const netAr = Math.max(grossAr - unappliedCredits, 0); return NextResponse.json({ summary: { - total_ar: parseFloat(s.total_ar ?? 0), - total_ar_count: parseInt(s.total_ar_count ?? 0), - current_balance: parseFloat(s.current_balance ?? 0), - current_count: parseInt(s.current_count ?? 0), - overdue_balance: parseFloat(s.overdue_balance ?? 0), - overdue_count: parseInt(s.overdue_count ?? 0), - paid_mtd: parseFloat(s.paid_mtd ?? 0), - paid_ytd: parseFloat(s.paid_ytd ?? 0), + total_ar: netAr, + total_ar_gross: grossAr, + unapplied_credits: unappliedCredits, + total_ar_count: parseInt(s.total_ar_count ?? 0), + current_balance: parseFloat(s.current_balance ?? 0), + current_count: parseInt(s.current_count ?? 0), + overdue_balance: parseFloat(s.overdue_balance ?? 0), + overdue_count: parseInt(s.overdue_count ?? 0), + paid_mtd: parseFloat(s.paid_mtd ?? 0), + paid_ytd: parseFloat(s.paid_ytd ?? 0), }, aging: { days_1_30: { balance: parseFloat(a.days_1_30 ?? 0), count: parseInt(a.cnt_1_30 ?? 0) }, diff --git a/app/api/qbo/diagnose-ar/route.ts b/app/api/qbo/diagnose-ar/route.ts new file mode 100644 index 0000000..d7cab14 --- /dev/null +++ b/app/api/qbo/diagnose-ar/route.ts @@ -0,0 +1,182 @@ +/** + * GET /api/qbo/diagnose-ar + * One-off reconciliation diagnostic — pulls QBO's Aged Receivable Detail + * report live and diffs it against Pulse's qbo_invoices to identify the + * gap between Pulse's headline A/R and what QBO reports. + * + * Returns per-invoice mismatches in three buckets: + * - in_qbo_not_in_pulse — QBO knows about it, Pulse doesn't (sync miss) + * - in_pulse_not_in_qbo — Pulse has live A/R for it, QBO doesn't + * (likely a tombstone candidate) + * - balance_diff — both have it but the balance differs + */ + +import { NextResponse } from 'next/server'; +import { requireAdmin } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; +import { QboClient } from '@/lib/services/qbo-client'; + +// QBO report rows are deeply nested — walk them and yield every leaf "Data" +// row. Each leaf carries an array of column values matching the report's +// column header (Customer | Date | Transaction Type | Num | Due Date | Aging | +// Open Balance, etc.). +function* walkRows(node: unknown): Generator<{ values: string[]; group?: string }> { + if (!node || typeof node !== 'object') return; + const n = node as Record; + if (Array.isArray(n.Row)) { + for (const child of n.Row as unknown[]) yield* walkRows(child); + return; + } + if (n.Rows) { + yield* walkRows(n.Rows); + return; + } + if (n.type === 'Data' && n.ColData && Array.isArray(n.ColData)) { + const values = (n.ColData as Array<{ value?: string }>).map((c) => c?.value ?? ''); + yield { values, group: typeof n.group === 'string' ? n.group : undefined }; + } + if (n.Header || n.Summary) { + // Section node — recurse into its rows + if (n.Rows) yield* walkRows(n.Rows); + } +} + +export async function GET() { + const { error } = await requireAdmin(); + if (error) return error; + const client = new QboClient(); + const report = await client.getAgedReceivableDetail(); + + // Map columns by ColTitle so we don't rely on positional order + const cols: Array<{ ColTitle?: string; ColType?: string }> = + (report.Columns?.Column ?? []) as Array<{ ColTitle?: string; ColType?: string }>; + const idx = (title: string) => cols.findIndex((c) => (c.ColTitle ?? '').toLowerCase() === title.toLowerCase()); + + const iNum = idx('Num'); + const iCust = idx('Customer'); + const iBalance = idx('Open Balance'); + const iType = idx('Transaction Type'); + const iAging = idx('Aging'); + + type QboLine = { + docNumber: string; + customer: string; + type: string; + aging: string; + balance: number; + }; + const qboInvoices: QboLine[] = []; + let qboTotal = 0; + for (const row of walkRows(report.Rows)) { + const v = row.values; + if (!v.length) continue; + const balanceStr = iBalance >= 0 ? v[iBalance] : ''; + const balance = balanceStr ? parseFloat(balanceStr) : 0; + const docNumber = iNum >= 0 ? v[iNum] : ''; + const customer = iCust >= 0 ? v[iCust] : ''; + const type = iType >= 0 ? v[iType] : ''; + const aging = iAging >= 0 ? v[iAging] : ''; + // Skip total/summary rows (no doc number, no customer) + if (!docNumber && !customer) continue; + qboInvoices.push({ docNumber, customer, type, aging, balance }); + qboTotal += balance; + } + + const pulseRes = await postgresClient.query<{ + id: string; + doc_number: string | null; + customer_ref_name: string | null; + balance: string; + status: string; + }>( + `SELECT id, doc_number, customer_ref_name, balance::text, status + FROM qbo_invoices + WHERE is_deleted = false + AND status IN ('Open','Overdue') + AND balance <> 0`, + ); + const pulseByDoc = new Map(); + let pulseTotal = 0; + for (const r of pulseRes.rows) { + const balance = parseFloat(r.balance); + pulseTotal += balance; + if (r.doc_number) { + pulseByDoc.set(r.doc_number, { + id: r.id, + customer: r.customer_ref_name ?? '', + balance, + status: r.status, + }); + } + } + + // Diff + const inQboNotInPulse: QboLine[] = []; + const balanceDiff: Array<{ + docNumber: string; + customer: string; + qboBalance: number; + pulseBalance: number; + delta: number; + }> = []; + const seenDocs = new Set(); + for (const inv of qboInvoices) { + if (!inv.docNumber) continue; + seenDocs.add(inv.docNumber); + const pulse = pulseByDoc.get(inv.docNumber); + if (!pulse) { + inQboNotInPulse.push(inv); + continue; + } + const delta = Math.round((inv.balance - pulse.balance) * 100) / 100; + if (Math.abs(delta) > 0.005) { + balanceDiff.push({ + docNumber: inv.docNumber, + customer: inv.customer || pulse.customer, + qboBalance: inv.balance, + pulseBalance: pulse.balance, + delta, + }); + } + } + const inPulseNotInQbo: Array<{ + docNumber: string; + customer: string; + balance: number; + status: string; + }> = []; + for (const [docNumber, p] of pulseByDoc) { + if (!seenDocs.has(docNumber)) { + inPulseNotInQbo.push({ + docNumber, + customer: p.customer, + balance: p.balance, + status: p.status, + }); + } + } + + const sumOf = (arr: T[], key: 'balance' | 'delta') => + Math.round(arr.reduce((s, x) => s + (x[key] ?? 0), 0) * 100) / 100; + + return NextResponse.json({ + summary: { + qbo_aging_total: Math.round(qboTotal * 100) / 100, + pulse_gross_ar: Math.round(pulseTotal * 100) / 100, + qbo_invoice_count: qboInvoices.length, + pulse_invoice_count: pulseRes.rows.length, + gap: Math.round((pulseTotal - qboTotal) * 100) / 100, + }, + differences: { + in_qbo_not_in_pulse: { count: inQboNotInPulse.length, sum: sumOf(inQboNotInPulse, 'balance'), rows: inQboNotInPulse }, + in_pulse_not_in_qbo: { count: inPulseNotInQbo.length, sum: sumOf(inPulseNotInQbo, 'balance'), rows: inPulseNotInQbo }, + balance_diff: { count: balanceDiff.length, sum: sumOf(balanceDiff, 'delta'), rows: balanceDiff }, + }, + report_meta: { + report_name: report.Header?.ReportName, + end_period: report.Header?.EndPeriod, + time: report.Header?.Time, + report_basis: report.Header?.ReportBasis, + }, + }); +} diff --git a/app/api/qbo/sync/route.ts b/app/api/qbo/sync/route.ts index 3298aba..b07f902 100644 --- a/app/api/qbo/sync/route.ts +++ b/app/api/qbo/sync/route.ts @@ -40,7 +40,7 @@ export async function POST(request: NextRequest) { export async function GET() { try { const [invoices, payments, deposits, transactions, reports] = await Promise.all([ - postgresClient.query(`SELECT COUNT(*) as count, MAX(synced_at) as last_sync FROM qbo_invoices`), + postgresClient.query(`SELECT COUNT(*) as count, MAX(synced_at) as last_sync FROM qbo_invoices WHERE is_deleted = false`), postgresClient.query(`SELECT COUNT(*) as count, MAX(synced_at) as last_sync FROM qbo_payments`), postgresClient.query(`SELECT COUNT(*) as count, MAX(synced_at) as last_sync FROM qbo_deposits`), postgresClient.query(`SELECT COUNT(*) as count, MAX(synced_at) as last_sync FROM qbo_transactions`), diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 39b2a39..d45868d 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -23,6 +23,7 @@ import { KpiCard } from '@/components/dashboard/kpi-card'; import { VolumeTrend } from '@/components/dashboard/volume-trend'; import { ResolutionTrend } from '@/components/dashboard/resolution-trend'; import { QueueHeatmap } from '@/components/dashboard/queue-heatmap'; +import { QueuePreferencesPopover } from '@/components/dashboard/queue-preferences-popover'; import { ActiveEngineers } from '@/components/dashboard/active-engineers'; import { RefreshCw, @@ -89,6 +90,32 @@ interface Trends { name: string; hours: number; ticketsTouched: number; + tickets: Array<{ + id: string; + ticketNumber: string | null; + title: string | null; + description: string | null; + statusLabel: string | null; + hours: number; + }>; + isPto: boolean; + ptoNote: string | null; + }>; + ptoEngineers: Array<{ + resourceId: string; + name: string; + hours: number; + ticketsTouched: number; + tickets: Array<{ + id: string; + ticketNumber: string | null; + title: string | null; + description: string | null; + statusLabel: string | null; + hours: number; + }>; + isPto: boolean; + ptoNote: string | null; }>; } @@ -270,11 +297,12 @@ export default function DashboardPage() { {/* QUEUE POSTURE ------------------------------------------------ */}
- + Queue posture + {!trends ? ( @@ -295,7 +323,10 @@ export default function DashboardPage() { {!trends ? ( ) : ( - + )} diff --git a/app/mobile/finance/page.tsx b/app/mobile/finance/page.tsx index 6d6b666..4257a8d 100644 --- a/app/mobile/finance/page.tsx +++ b/app/mobile/finance/page.tsx @@ -26,6 +26,7 @@ interface AgingBucket { balance: number; count: number; } interface FinanceData { summary: { total_ar: number; total_ar_count: number; + total_ar_gross?: number; unapplied_credits?: number; current_balance: number; current_count: number; overdue_balance: number; overdue_count: number; paid_mtd: number; paid_ytd: number; @@ -178,7 +179,11 @@ export default function MobileFinance() { 0 + ? `${summary.total_ar_count} open · −${fmt$(summary.unapplied_credits)} credits` + : `${summary.total_ar_count} open invoices` + } /> (null); + const [ptoOpen, setPtoOpen] = useState(false); + + if (working.length === 0 && pto.length === 0) { return ( Math.max(m, e.hours), 0) || 1; - const totalHours = data.reduce((s, e) => s + e.hours, 0); - const totalTickets = data.reduce((s, e) => s + e.ticketsTouched, 0); + const max = working.reduce((m, e) => Math.max(m, e.hours), 0) || 1; + const totalHours = working.reduce((s, e) => s + e.hours, 0); + const totalTickets = working.reduce((s, e) => s + e.ticketsTouched, 0); return ( -
- {data.map((e) => { - const pct = (e.hours / max) * 100; - return ( -
-
-
{e.name}
-
-
+
+ {working.length === 0 ? ( +
+ No working time logged yet today. +
+ ) : ( +
+ {working.map((e) => { + const pct = (e.hours / max) * 100; + return ( +
+
+
{e.name}
+
+
+
+
+
+
{e.hours.toFixed(1)}h
+ +
-
-
-
{e.hours.toFixed(1)}h
-
- {e.ticketsTouched}{' '} - ticket{e.ticketsTouched === 1 ? '' : 's'} -
-
+ ); + })} +
+ Total today + + {totalHours.toFixed(1)}h{' '} + across {totalTickets}{' '} + ticket{totalTickets === 1 ? '' : 's'} +
- ); - })} -
- Total today - - {totalHours.toFixed(1)}h{' '} - across {totalTickets}{' '} - ticket{totalTickets === 1 ? '' : 's'} - -
+
+ )} + + {pto.length > 0 && ( + + + + + + {pto.length} on PTO / Vacation + + + + + +
    + {pto.map((e) => ( +
  • + {e.name} + {e.ptoNote ? ( + + {e.ptoNote} + + ) : null} +
  • + ))} +
+
+
+ )} + + !open && setTicketsFor(null)}> + + + {ticketsFor?.name} · today + + {ticketsFor + ? `${ticketsFor.hours.toFixed(1)}h across ${ticketsFor.ticketsTouched} ticket${ + ticketsFor.ticketsTouched === 1 ? '' : 's' + }` + : ''} + + +
    + {ticketsFor?.tickets.map((t) => ( + + ))} +
+
+
); } + +/* Single ticket row inside the per-engineer dialog. Hovering anywhere on the + * row reveals a popover with the ticket's current status and description. */ +function TicketRow({ ticket: t }: { ticket: Ticket }) { + const [open, setOpen] = useState(false); + const hasHoverDetail = !!(t.description || t.statusLabel); + + const row = ( +
  • setOpen(true) : undefined} + onMouseLeave={hasHoverDetail ? () => setOpen(false) : undefined} + > +
    + + {t.ticketNumber ?? `#${t.id}`} + + {t.title ? ( +
    {t.title}
    + ) : null} +
    + {t.hours.toFixed(2)}h +
  • + ); + + if (!hasHoverDetail) return row; + + return ( + + {row} + e.preventDefault()} + > +
    + {t.statusLabel ? ( +
    + Status + + {t.statusLabel} + +
    + ) : null} + {t.description ? ( +
    +
    Description
    +

    + {t.description} +

    +
    + ) : ( +
    + No description on this ticket. +
    + )} +
    +
    +
    + ); +} diff --git a/components/dashboard/queue-preferences-popover.tsx b/components/dashboard/queue-preferences-popover.tsx new file mode 100644 index 0000000..6090c9b --- /dev/null +++ b/components/dashboard/queue-preferences-popover.tsx @@ -0,0 +1,137 @@ +'use client'; + +/* Queue preferences popover — gear button on the Queue posture card. + * + * Lists every active queue with a Switch per row. Toggling persists to + * /api/me/queue-preferences (full-set PUT) and calls onSaved() so the + * parent can refetch trends and the heatmap drops the hidden rows. + * + * State is owned by this component; it lazy-loads queue data on first + * open to keep the dashboard's initial paint cheap. */ + +import { useEffect, useState } from 'react'; +import { Settings2, Loader2, Search } from 'lucide-react'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Switch } from '@/components/ui/switch'; +import { Input } from '@/components/ui/input'; + +interface QueueRow { + id: number; + label: string; + hidden: boolean; +} + +interface Props { + onSaved: () => void; +} + +export function QueuePreferencesPopover({ onSaved }: Props) { + const [open, setOpen] = useState(false); + const [queues, setQueues] = useState(null); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(null); + const [filter, setFilter] = useState(''); + + useEffect(() => { + if (!open || queues !== null) return; + setLoading(true); + fetch('/api/me/queue-preferences') + .then((r) => r.json()) + .then((data: { queues: QueueRow[] }) => setQueues(data.queues)) + .catch(() => toast.error('Failed to load queues')) + .finally(() => setLoading(false)); + }, [open, queues]); + + async function toggle(queueId: number, nextHidden: boolean) { + if (!queues) return; + const previous = queues; + const next = queues.map((q) => (q.id === queueId ? { ...q, hidden: nextHidden } : q)); + setQueues(next); + setSaving(queueId); + try { + const hiddenIds = next.filter((q) => q.hidden).map((q) => q.id); + const res = await fetch('/api/me/queue-preferences', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hiddenIds }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + onSaved(); + } catch { + setQueues(previous); + toast.error('Failed to update queue preference'); + } finally { + setSaving(null); + } + } + + const visible = queues?.filter((q) => + filter ? q.label.toLowerCase().includes(filter.toLowerCase()) : true, + ); + + return ( + + + + + +
    +

    Visible queues

    +

    + Toggle off to hide a queue from your dashboard. +

    +
    +
    +
    + + setFilter(e.target.value)} + placeholder="Filter queues…" + className="pl-7 h-8 text-sm" + /> +
    +
    +
    + {loading || queues === null ? ( +
    + +
    + ) : visible && visible.length === 0 ? ( +

    + No queues match. +

    + ) : ( +
      + {visible!.map((q) => ( +
    • + + {q.label} + + toggle(q.id, !checked)} + disabled={saving === q.id} + aria-label={`${q.hidden ? 'Show' : 'Hide'} ${q.label}`} + /> +
    • + ))} +
    + )} +
    +
    +
    + ); +} diff --git a/docker-compose.yml b/docker-compose.yml index 10c10e0..6ab9d2b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,11 @@ services: interval: 5s timeout: 3s retries: 5 + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" # PostgreSQL database for Autotask sync postgres: @@ -36,6 +41,11 @@ services: interval: 10s timeout: 5s retries: 5 + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" # Next.js application on port 3100 (instead of 3000) app: @@ -113,7 +123,7 @@ services: # IT Glue Configuration ITGLUE_API_KEY: ${ITGLUE_API_KEY} - + # Backblaze B2 Storage (S3-compatible) B2_KEY_ID: ${B2_KEY_ID} B2_APP_KEY: ${B2_APP_KEY} @@ -140,6 +150,11 @@ services: volumes: # Mount .env.local for development (remove in production) - ./.env.local:/app/.env.local:ro + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" volumes: redis_data: diff --git a/docs/superpowers/specs/2026-05-03-mobile-shell-design.md b/docs/superpowers/specs/2026-05-03-mobile-shell-design.md new file mode 100644 index 0000000..0e11f6c --- /dev/null +++ b/docs/superpowers/specs/2026-05-03-mobile-shell-design.md @@ -0,0 +1,221 @@ +# Mobile Shell Redesign — Design Spec + +**Date:** 2026-05-03 +**Scope:** `/mobile/*` shell, navigation, and per-page layouts on Pulse. + +## 1. Goal & audience + +Pulse's mobile shell exists for **managers on the go** — quick status checks, +triage decisions, and read-only awareness. It is not a full replacement for the +desktop app; pages where mobile editing isn't justified should link out to the +desktop equivalent. + +Success means a manager can: + +- See the state of the business at a glance (Dashboard). +- Triage and inspect tickets (Tickets). +- Read AR / invoice / payment status (Finance). +- Skim recent AI ticket analyses (Analyzer). +- Reach Engagement and sign-out without clutter on the primary nav. + +## 2. Approach + +**Rebuild `/mobile` in place.** Keep all existing `/mobile/*` route paths +(`/mobile/dashboard`, `/mobile/tickets`, `/mobile/tickets/[id]`, +`/mobile/finance`). Replace `/mobile/layout.tsx`, the four page files, and +delete `/mobile/nav` (its content moves into a Sheet drawer — see §3). + +No new top-level routes. No parallel `/mobile-v2` directory. The existing pages +stay their canonical URLs throughout the redesign. + +## 3. Navigation + +### 3.1 Bottom tab bar (primary) + +Four tabs, equal-width, fixed to the bottom of the viewport: + +| Tab | Icon (lucide) | Route | +|------------|-------------------|---------------------| +| Dashboard | `LayoutDashboard` | `/mobile/dashboard` | +| Tickets | `Ticket` | `/mobile/tickets` | +| Finance | `DollarSign` | `/mobile/finance` | +| Analyzer | `Sparkles` | `/mobile/analyzer` | + +Active state: `text-primary`; inactive: `text-muted-foreground`. Active +detection via `pathname.startsWith(href)`. + +### 3.2 "More" Sheet drawer (secondary) + +A fifth control on the bottom bar — `Menu` icon labelled **More** — opens a +shadcn `Sheet` (side="right", or "bottom" on phones; pick one and stay +consistent). The drawer **replaces the standalone `/mobile/nav` page entirely**. + +Drawer contents, top to bottom: + +1. **Mobile sections** — Engagement (`/mobile/engagement`, see §6.5). +2. **Full site** — link list to desktop pages that have no mobile view yet + (Quotes, Configuration Items, Backup Status, Ticket Digest, Admin / Sync). + Each link uses the `ExternalLink` icon hint to signal "leaves mobile shell". +3. **Account** — current user (avatar + email, read-only), then **Sign out** + (calls `signOut()` then `router.push('/auth/sign-in')`). + +Delete `app/mobile/nav/page.tsx` after the drawer is in place. + +## 4. PWA setup + +- `public/manifest.json` — name "Pulse", short_name "Pulse", `display: "standalone"`, + `start_url: "/mobile"`, theme/background colors matching the dark and light + shells. Reference it from `app/layout.tsx` via ``. +- `viewport` meta in `app/layout.tsx` (or root metadata): include + `viewport-fit=cover` so the shell can paint behind the home indicator. +- **Safe-area insets** — both the sticky header and the bottom tab bar add + `env(safe-area-inset-top)` / `env(safe-area-inset-bottom)` padding. Use + Tailwind arbitrary values (`pt-[env(safe-area-inset-top)]`, + `pb-[env(safe-area-inset-bottom)]`) or a small utility class. +- **No service worker, no offline mode** in this iteration. Don't ship + `next-pwa` or a custom SW — deferred until a clear offline use-case lands. +- **Tablet** — `md:max-w-2xl mx-auto` wrapper is noted as a follow-up but is + **not** part of this spec's deliverable. Keep the current `max-w-lg`. + +## 5. Shell (`app/mobile/layout.tsx`) + +### 5.1 Header + +Sticky top, `bg-background/95 backdrop-blur`, bottom border. Three slots: + +- **Left:** Wulf mark (existing logo asset) + "Pulse" wordmark, linked to + `/mobile/dashboard`. Use the actual brand mark, not the text-only fallback. +- **Right (in order):** + - `Bell` icon button — **placeholder only**, no menu, no badge logic. Wire + to `aria-label="Notifications"` and an empty `onClick` so it's + keyboard-accessible. A future phase will turn this into a real list. + - Compact user avatar (`Avatar` from shadcn, size `h-7 w-7`). On tap, + opens the More drawer (so the avatar is the entry point alongside the + bottom-bar More button). + +No page title in the header — pages render their own H1 in the content area. + +### 5.2 Content area + +`
    ` between header and bottom nav, scrollable. Add bottom padding equal to +the bottom-nav height + safe-area inset so content doesn't hide under the bar. + +### 5.3 Bottom nav + +Fixed, full-width, `border-t bg-background`. Wraps in `max-w-lg mx-auto` so +content and nav share the same gutter. Five cells: 4 tabs (§3.1) + More (§3.2). + +## 6. Pages + +### 6.1 Dashboard (`/mobile/dashboard`) + +Sections, top to bottom: + +1. **2×2 KPI grid** — four primary metric cards. KPIs to choose from the + desktop dashboard's hero stats; final selection during build, but the layout + is fixed at 2×2 on phone widths. +2. **"Needs Attention" strip** — horizontal scroll of compact cards + surfacing items that require manager action (overdue tickets, failed + backups, stalled workflows). Each card opens its detail view directly. +3. **Backup / worker status** — compact status row showing the analyzer + worker, RMM worker, and backup-success-rate at a glance. Read-only; tap + opens the desktop admin page. + +No charts on the mobile Dashboard in this iteration — recharts on small widths +isn't earning its weight. + +### 6.2 Tickets (`/mobile/tickets`) + +- **Collapsible filter strip** at top (`Collapsible` from shadcn, default + collapsed). When expanded: status, priority, queue, assigned-to-me toggle. + Filter state syncs to the URL query string so deep-linking works. +- **Priority-bar rows** — list rows have a left-edge color stripe by priority + (Critical/High/Medium/Low → red/orange/amber/slate). Body shows ticket #, + title, company, age, assignee. Single-tap opens detail. +- **Infinite scroll** — replace the current pagination with cursor-based + infinite scroll. Fetch in pages of ~25; trigger next page when the last row + enters the viewport (IntersectionObserver). Keep a "Load more" fallback button + for accessibility. +- **Detail page** — keep `/mobile/tickets/[id]/page.tsx` largely as-is; only + reskin the header to match the new shell (Wulf mark, breadcrumb back). + +### 6.3 Finance (`/mobile/finance`) + +Restyle of the existing page only. No new data, no new sections. Adopt the new +Card and typography scale, fix any spacing that breaks on small phones. If a +section currently relies on a wide table, swap it for a stacked list on mobile. + +### 6.4 Analyzer (`/mobile/analyzer`) — NEW + +**Read-only feed of recent AI ticket analyses.** + +- List view: most-recent-first stream of analyses. Each row shows ticket #, + title, the analyzer's one-line summary, confidence badge, and a stage + indicator (Triage → Analyze → Deep Review). Tap opens a mobile summary view. +- Mobile summary view: renders **Summary**, **Next Step**, **Next Step + Rationale** (all already produced by the analyzer pipeline). Includes a "View + full analysis" link out to the desktop analyzer page. +- No editing, no re-run, no prompt tuning on mobile. +- Source data: existing `analyzer_analyses` rows via a new + `/api/mobile/analyzer/feed` endpoint (or reuse an existing list endpoint if + one already returns the right shape). + +### 6.5 Engagement (`/mobile/engagement`) — NEW, accessed via More drawer + +Engagement gets a **real mobile refactor**, not a thin adaptation. The desktop +page (`app/engagement/page.tsx`, ~1300 lines) and profile page +(`app/engagement/profile/page.tsx`, ~650 lines) both rely on wide tables, dense +charts, and modal patterns that don't translate to phone widths. Build the +mobile views from the same data sources but with phone-first layouts. + +**Overview (`/mobile/engagement`):** + +- Period selector chip row (today / 7d / 30d) — sticky just below the page H1. +- Summary cards stacked single-column (active users, total Graph hours, total + Autotask hours, hours-per-active-user). No 4-up grid; reading numbers across + a 4-up row on a phone is a non-starter. +- Per-employee list as stacked rows (avatar / initials, name, role, hours + bar). Sortable via a small control above the list (sort by hours, name, + utilization). Search input above the list. +- Charts: replace the desktop's wide bar/line charts with one compact "hours + trend" sparkline at the top of the list, period-scoped. No multi-series + chart on mobile in this iteration. + +**User profile (`/mobile/engagement/profile?userId=...` or +`/mobile/engagement/[userId]`):** + +- Reuses the existing profile data endpoints. Layout is single-column: + identity header → period selector → key metrics (compact) → activity + breakdown list → recent items. +- This **replaces** the desktop user-detail modal pattern on mobile — + tapping a row navigates to a real page, not a modal, so the shell's back + gesture works correctly. +- Pick exactly one of the two route shapes above during build (prefer the + `[userId]` segment form for shareable URLs); don't ship both. + +Engagement is **not** on the bottom bar — it lives in the More drawer because +managers don't check it as often as the four primary surfaces. + +## 7. Out of scope (explicit non-goals) + +- Service worker, offline cache, push notifications. +- Tablet breakpoint (md:max-w-2xl) — noted, deferred. +- Real notification list behind the bell. +- Mobile editing on Engagement (user detail) or Analyzer (re-run, prompt edits). +- Charts / recharts on mobile Dashboard. +- Replacing or restyling the desktop pages reachable from the More drawer. + +## 8. Build order + +1. PWA scaffolding — `manifest.json`, viewport meta, safe-area utility. +2. New `app/mobile/layout.tsx` — header, More drawer (Sheet), 5-cell bottom nav. + Delete `app/mobile/nav/page.tsx` in the same change. +3. Dashboard restyle (2×2 grid, Needs Attention, status row). +4. Tickets restyle (filter Collapsible, priority-bar rows, infinite scroll). + Detail page header reskin only. +5. Finance restyle. +6. Analyzer page + `/api/mobile/analyzer/feed` endpoint. +7. Engagement Overview (`/mobile/engagement`) — phone-first layout from existing data sources. +8. Engagement user profile (`/mobile/engagement/[userId]`) — replaces the desktop modal pattern with a real mobile page. + +Each step ships independently — no big-bang merge. diff --git a/lib/services/qbo-sync-service.ts b/lib/services/qbo-sync-service.ts index e270d44..1899702 100644 --- a/lib/services/qbo-sync-service.ts +++ b/lib/services/qbo-sync-service.ts @@ -62,8 +62,9 @@ export class QboSyncService { updatedSince = await this.getLastSyncTime(); } - // Sync invoices - entities.push(await this.syncInvoices(realmId, updatedSince)); + // Sync invoices. Pass syncType so a full sync can tombstone any + // rows QBO no longer returns (voided / deleted there). + entities.push(await this.syncInvoices(realmId, updatedSince, syncType)); // Sync payments entities.push(await this.syncPayments(realmId, updatedSince)); @@ -111,14 +112,20 @@ export class QboSyncService { // ─── Entity Sync Methods ─────────────────────────────────────────────────── - private async syncInvoices(realmId: string, updatedSince?: Date): Promise { + private async syncInvoices( + realmId: string, + updatedSince?: Date, + syncType: 'full' | 'incremental' = 'incremental', + ): Promise { const start = Date.now(); try { const invoices = await this.client.getInvoices(updatedSince); console.log(`[QboSync] Fetched ${invoices.length} invoices`); let upserted = 0; + const seenIds: string[] = []; for (const inv of invoices) { + if (inv.Id) seenIds.push(inv.Id); const status = this.deriveInvoiceStatus(inv); await postgresClient.query( `INSERT INTO qbo_invoices @@ -141,7 +148,9 @@ export class QboSyncService { linked_txns = EXCLUDED.linked_txns, sync_token = EXCLUDED.sync_token, qbo_updated_at = EXCLUDED.qbo_updated_at, - synced_at = NOW()`, + synced_at = NOW(), + is_deleted = false, + deleted_at = NULL`, [ inv.Id, realmId, inv.DocNumber ?? null, inv.TxnDate ?? null, inv.DueDate ?? null, @@ -159,7 +168,40 @@ export class QboSyncService { upserted++; } - return { entity: 'invoices', success: true, recordsUpserted: upserted, duration: Date.now() - start }; + // Tombstone pass — full syncs only. + // + // QBO's Invoice query never reports deletions; voided/deleted invoices + // just stop appearing. A full sync just fetched every live invoice + // for this realm, so any Pulse row not in `seenIds` is an orphan and + // should be soft-deleted so it stops counting toward A/R. + // + // Incremental syncs are skipped here — they only see recently-changed + // rows, so applying the same logic would tombstone the entire ledger. + let tombstoned = 0; + if (syncType === 'full' && seenIds.length > 0) { + const tombstoneRes = await postgresClient.query<{ id: string }>( + `UPDATE qbo_invoices + SET is_deleted = true, + deleted_at = NOW() + WHERE realm_id = $1 + AND is_deleted = false + AND id <> ALL($2::text[]) + RETURNING id`, + [realmId, seenIds], + ); + tombstoned = tombstoneRes.rowCount ?? tombstoneRes.rows.length; + if (tombstoned > 0) { + console.log(`[QboSync] Tombstoned ${tombstoned} invoice(s) absent from full-sync response`); + } + } + + return { + entity: 'invoices', + success: true, + recordsUpserted: upserted, + recordsDeleted: tombstoned, + duration: Date.now() - start, + }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.error(`[QboSync] Invoice sync failed: ${msg}`); diff --git a/migrations/087_user_queue_preferences.sql b/migrations/087_user_queue_preferences.sql new file mode 100644 index 0000000..ab5fce8 --- /dev/null +++ b/migrations/087_user_queue_preferences.sql @@ -0,0 +1,23 @@ +-- ============================================================================= +-- Per-user queue preferences +-- ============================================================================= +-- Lets each user hide specific queues from their own dashboard widgets +-- (notably the Queue posture heatmap). Presence of a row = that user has +-- hidden that queue. Absence = visible. +-- +-- Independent of the global company_scope filter — that affects KPI counts +-- for everyone, this only affects what an individual user chooses to see. +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS user_queue_preferences ( + user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + queue_id INTEGER NOT NULL REFERENCES queues(value) ON DELETE CASCADE, + hidden_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, queue_id) +); + +CREATE INDEX IF NOT EXISTS idx_user_queue_preferences_user + ON user_queue_preferences(user_id); + +COMMENT ON TABLE user_queue_preferences IS + 'Per-user hidden-queue list. Row presence = queue is hidden for that user in dashboard widgets.'; diff --git a/migrations/088_qbo_invoices_soft_delete.sql b/migrations/088_qbo_invoices_soft_delete.sql new file mode 100644 index 0000000..060cf11 --- /dev/null +++ b/migrations/088_qbo_invoices_soft_delete.sql @@ -0,0 +1,17 @@ +-- Soft-delete support for qbo_invoices. +-- +-- QBO's Invoice query endpoint only returns rows that still exist in QBO. +-- When an invoice is voided or deleted in QBO, it simply stops appearing in +-- responses — there is no "deleted" flag we can subscribe to. Without a +-- tombstone pass these orphan rows linger in Pulse forever and keep +-- inflating Total A/R (see the TNT Pizza incident, 2026-05-15). +-- +-- A full QBO sync now compares the set of returned invoice IDs against +-- what Pulse has on file and flips is_deleted = true for any row the +-- query no longer returns. Finance queries filter on is_deleted = false. + +ALTER TABLE qbo_invoices + ADD COLUMN IF NOT EXISTS is_deleted BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; + +CREATE INDEX IF NOT EXISTS idx_qbo_invoices_is_deleted ON qbo_invoices(is_deleted); diff --git a/scripts/check-699456-parse.ts b/scripts/check-699456-parse.ts new file mode 100644 index 0000000..a2bcb70 --- /dev/null +++ b/scripts/check-699456-parse.ts @@ -0,0 +1,12 @@ +import { config } from 'dotenv'; +import { resolve } from 'path'; +config({ path: resolve('/opt/stacks/pulse/.env.local') }); + +import { parseAndStoreMessage } from '../lib/services/phishing-eml-service'; + +async function main() { + const result = await parseAndStoreMessage({ reportId: '6bdd5c3f-0844-4673-941b-d5b438739383', ticketId: 699456 }); + console.log(JSON.stringify(result, null, 2)); + process.exit(0); +} +main().catch((err) => { console.error(err); process.exit(1); }); diff --git a/scripts/check-suspect-tickets.ts b/scripts/check-suspect-tickets.ts new file mode 100644 index 0000000..c8370b7 --- /dev/null +++ b/scripts/check-suspect-tickets.ts @@ -0,0 +1,31 @@ +import { config } from 'dotenv'; +import { resolve } from 'path'; +config({ path: resolve(__dirname, '../.env.local') }); + +const SUSPECTS = [573672, 568742, 541321, 532302]; + +const headers = { + Username: process.env.AUTOTASK_USERNAME!, + Secret: process.env.AUTOTASK_SECRET!, + APIIntegrationcode: process.env.AUTOTASK_API_INTEGRATION_CODE!, + Accept: 'application/json', +}; + +async function main() { + for (const id of SUSPECTS) { + const res = await fetch(`${process.env.AUTOTASK_API_URL}/Tickets/${id}`, { headers }); + const data = await res.json(); + const t = data.item; + if (!t) { + console.log(`Ticket ${id}: NOT FOUND IN AUTOTASK`); + continue; + } + console.log(`Ticket ${id} (${t.ticketNumber}):`); + for (const f of ['title','purchaseOrderNumber','ticketNumber','changeInfoField1','changeInfoField2','changeInfoField3','changeInfoField4','changeInfoField5']) { + const v = t[f]; + const len = typeof v === 'string' ? v.length : 0; + if (len > 0) console.log(` ${f.padEnd(24)} len=${len}${len > (f === 'purchaseOrderNumber' || f === 'ticketNumber' ? 100 : 255) ? ' << OVERFLOW' : ''}`); + } + } +} +main().catch(console.error); diff --git a/scripts/diagnose-ticket-varchar-overflow.ts b/scripts/diagnose-ticket-varchar-overflow.ts new file mode 100644 index 0000000..0af118e --- /dev/null +++ b/scripts/diagnose-ticket-varchar-overflow.ts @@ -0,0 +1,192 @@ +/** + * diagnose-ticket-varchar-overflow.ts + * + * One-shot: identify Autotask Tickets whose string fields exceed the + * varchar() limits declared on the local postgres `tickets` table. + * + * Why: every full tickets sync since 2026-05-21 16:14 fails with + * `value too long for type character varying(255)` + * during bulkUpsert. The pg error doesn't name the column, so we replicate + * the real sync's Autotask query EXACTLY (createDate filter, no IncludeFields) + * and walk the response checking every string field on every record. + * + * Stops after first 10 violations found. + * + * Usage: + * npx tsx scripts/diagnose-ticket-varchar-overflow.ts + */ + +import { config } from 'dotenv'; +import { resolve } from 'path'; + +config({ path: resolve(__dirname, '../.env.local') }); + +const API_BASE = process.env.AUTOTASK_API_URL!; +const USERNAME = process.env.AUTOTASK_USERNAME!; +const SECRET = process.env.AUTOTASK_SECRET!; +const INT_CODE = process.env.AUTOTASK_API_INTEGRATION_CODE!; + +if (!API_BASE || !USERNAME || !SECRET || !INT_CODE) { + console.error('Missing Autotask credentials in env (.env.local)'); + process.exit(1); +} + +// Postgres tickets table varchar column limits (keep in sync with schema). +// Autotask camelCase field name -> postgres column limit. +const FIELD_LIMITS: Record = { + title: 255, + purchaseOrderNumber: 100, + ticketNumber: 100, + changeInfoField1: 255, + changeInfoField2: 255, + changeInfoField3: 255, + changeInfoField4: 255, + changeInfoField5: 255, +}; + +function authHeaders(): Record { + return { + Username: USERNAME, + Secret: SECRET, + APIIntegrationcode: INT_CODE, + 'Content-Type': 'application/json', + Accept: 'application/json', + }; +} + +interface Violation { + ticketId: number; + ticketNumber?: string; + field: string; + length: number; + limit: number; + preview: string; + createDate?: string; +} + +const MAX_VIOLATIONS = 10; + +async function main() { + const since = new Date(); + since.setUTCFullYear(since.getUTCFullYear() - 2); + const sinceIso = since.toISOString(); + + console.log(`Scanning Tickets with createDate >= ${sinceIso} (matches real sync filter exactly)`); + console.log(`Field limits: ${JSON.stringify(FIELD_LIMITS)}`); + console.log(`Will also report ANY string field >255 chars even if not in known limit list.`); + console.log(''); + + // Real sync sends ONLY {MaxRecords, filter} — no IncludeFields. Match exactly. + const baseBody = { + MaxRecords: 500, + filter: [{ field: 'createDate', op: 'gte', value: sinceIso }], + }; + + const violations: Violation[] = []; + const unknownFieldOverflows: Violation[] = []; + let totalScanned = 0; + let pageIdx = 0; + + let url: string | null = `${API_BASE}/Tickets/query`; + while (url) { + pageIdx += 1; + const res: Response = await fetch(url, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify(baseBody), + }); + if (!res.ok) { + const t = await res.text(); + throw new Error(`Autotask query failed (page ${pageIdx}): ${res.status} ${t.slice(0, 500)}`); + } + const data = await res.json(); + const items: any[] = data.items || []; + totalScanned += items.length; + + for (const rec of items) { + for (const [field, v] of Object.entries(rec)) { + if (typeof v !== 'string') continue; + const limit = FIELD_LIMITS[field]; + if (limit !== undefined && v.length > limit) { + violations.push({ + ticketId: rec.id, + ticketNumber: rec.ticketNumber, + field, + length: v.length, + limit, + preview: v.slice(0, 100) + (v.length > 100 ? '…' : ''), + createDate: rec.createDate, + }); + } else if (limit === undefined && v.length > 255) { + // Any other string field >255 (could explain why we missed it earlier). + unknownFieldOverflows.push({ + ticketId: rec.id, + ticketNumber: rec.ticketNumber, + field, + length: v.length, + limit: -1, + preview: v.slice(0, 100) + '…', + createDate: rec.createDate, + }); + } + } + } + + process.stdout.write( + ` page ${pageIdx}: scanned ${items.length} (total ${totalScanned}, hits known=${violations.length} other>255=${unknownFieldOverflows.length})\n` + ); + + if (violations.length >= MAX_VIOLATIONS) { + console.log(`\nReached ${MAX_VIOLATIONS} known-field violations, stopping early.`); + break; + } + + url = data.pageDetails?.nextPageUrl || null; + } + + console.log(''); + console.log(`Scan ended. Pages: ${pageIdx}. Tickets scanned: ${totalScanned}.`); + console.log(`Violations on KNOWN columns: ${violations.length}`); + console.log(`Other string fields >255 chars: ${unknownFieldOverflows.length}`); + console.log(''); + + if (violations.length === 0 && unknownFieldOverflows.length === 0) { + console.log('NO violations found. The offending record may have been modified since the failed sync.'); + process.exit(0); + } + + if (violations.length > 0) { + const byField: Record = {}; + for (const v of violations) { + const b = byField[v.field] || { count: 0, maxLen: 0 }; + b.count += 1; + b.maxLen = Math.max(b.maxLen, v.length); + byField[v.field] = b; + } + console.log('Known-column violations by field:'); + for (const [field, { count, maxLen }] of Object.entries(byField)) { + console.log(` ${field.padEnd(24)} count=${count} maxLen=${maxLen} limit=${FIELD_LIMITS[field]}`); + } + console.log(''); + console.log('First 10 violations:'); + for (const v of violations.slice(0, 10)) { + console.log( + ` ticket ${v.ticketId} (${v.ticketNumber ?? '?'}) field=${v.field} length=${v.length} createDate=${v.createDate}` + ); + console.log(` preview: ${v.preview.replace(/\s+/g, ' ')}`); + } + } + + if (unknownFieldOverflows.length > 0) { + console.log(''); + console.log('Other (unmapped) string fields >255 chars — these would NOT cause the postgres error but worth noting:'); + const byOther: Record = {}; + for (const v of unknownFieldOverflows) byOther[v.field] = (byOther[v.field] || 0) + 1; + for (const [f, c] of Object.entries(byOther)) console.log(` ${f.padEnd(28)} count=${c}`); + } +} + +main().catch((err) => { + console.error('FATAL:', err); + process.exit(1); +}); diff --git a/scripts/list-rmm-sites.ts b/scripts/list-rmm-sites.ts new file mode 100644 index 0000000..db771c1 --- /dev/null +++ b/scripts/list-rmm-sites.ts @@ -0,0 +1,22 @@ +import { config } from 'dotenv'; +import { resolve } from 'path'; +config({ path: resolve('/opt/stacks/pulse/.env.local') }); + +import { getDattoRMMClient } from '../lib/services/datto-rmm-factory'; + +async function main() { + const client = getDattoRMMClient(); + const sites = await client.getSites(); + + console.log(`Found ${sites.length} sites\n`); + console.log('NAME\tUID\tDEVICES\tON-DEMAND'); + for (const s of sites.sort((a, b) => a.name.localeCompare(b.name))) { + const devs = s.devicesStatus?.numberOfDevices ?? ''; + console.log(`${s.name}\t${s.uid}\t${devs}\t${s.onDemand}`); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/peek-ticket.ts b/scripts/peek-ticket.ts new file mode 100644 index 0000000..c0da347 --- /dev/null +++ b/scripts/peek-ticket.ts @@ -0,0 +1,26 @@ +import { config } from 'dotenv'; +import { resolve } from 'path'; +config({ path: resolve('/opt/stacks/pulse/.env.local') }); + +async function main() { + const res = await fetch(`${process.env.AUTOTASK_API_URL}/Tickets/query`, { + method: 'POST', + headers: { + Username: process.env.AUTOTASK_USERNAME!, + Secret: process.env.AUTOTASK_SECRET!, + APIIntegrationcode: process.env.AUTOTASK_API_INTEGRATION_CODE!, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ MaxRecords: 1, filter: [{ field: 'id', op: 'gt', value: 0 }] }), + }); + const data = await res.json(); + const rec = data.items[0]; + console.log('All string fields:'); + for (const [k, v] of Object.entries(rec).sort()) { + if (typeof v === 'string') console.log(` ${k.padEnd(45)} len=${(v as string).length}`); + } + console.log('\nAll field names (for reference):'); + console.log(Object.keys(rec).sort().join(', ')); +} +main().catch(console.error); diff --git a/scripts/reanalyze-seubert-burst.ts b/scripts/reanalyze-seubert-burst.ts new file mode 100644 index 0000000..8f46f31 --- /dev/null +++ b/scripts/reanalyze-seubert-burst.ts @@ -0,0 +1,105 @@ +/** + * reanalyze-seubert-burst.ts + * + * One-shot: (1) reclassify ticket 699415's campaign now that the container + * has been rebuilt with the Phase 23 USER_AWARENESS classifier (the existing + * classification ran against the pre-Phase-23 code and is stale), and + * (2) run /analyze's parse+group steps for the 7 Seubert reports (699419, + * 699421, 699422, 699433, 699435, 699441, 699445) that landed before the + * company's automation gate was switched on and never got EML-parsed. + * + * Usage: + * POSTGRES_HOST=localhost npx tsx scripts/reanalyze-seubert-burst.ts + */ + +import { config } from 'dotenv'; +import { resolve } from 'path'; +config({ path: resolve('/opt/stacks/pulse/.env.local') }); + +import postgresClient from '../lib/services/postgres-client'; +import { detectPhishingTicket, type DetectableTicket } from '../lib/services/phishing-detector'; +import { parseAndStoreMessage } from '../lib/services/phishing-eml-service'; +import { groupReportIntoCampaign } from '../lib/services/campaign-grouping-service'; +import { classifyCampaign } from '../lib/services/campaign-classifier'; + +const RECLASSIFY_CAMPAIGN_ID = '60a3613c-5b6c-45b1-8441-411a3fbf100c'; // ticket 699415 +const UNPARSED_TICKET_IDS = [699419, 699421, 699422, 699433, 699435, 699441, 699445]; + +async function reanalyzeTicket(ticketId: number) { + const row = await postgresClient.query<{ + id: string; + ticket_number: string | null; + title: string | null; + description: string | null; + company_id: number | null; + contact_id: number | null; + created_by_contact_id: number | null; + }>( + `SELECT id, ticket_number, title, description, company_id, contact_id, created_by_contact_id + FROM tickets WHERE id = $1`, + [ticketId] + ); + const r = row.rows[0]; + if (!r) { + console.log(` ticket ${ticketId}: NOT FOUND in Postgres`); + return; + } + + const ticket: DetectableTicket = { + id: Number(r.id), + ticket_number: r.ticket_number, + title: r.title, + description: r.description, + company_id: r.company_id, + contact_id: r.contact_id, + created_by_contact_id: r.created_by_contact_id, + }; + + const detection = await detectPhishingTicket(ticket); + if (!detection.flagged || !detection.reportId) { + console.log(` ticket ${ticketId}: detector did not flag it (unexpected — it already has a reports row)`); + return; + } + + const parseResult = await parseAndStoreMessage({ reportId: detection.reportId, ticketId }); + const grouped = await groupReportIntoCampaign(detection.reportId); + + console.log( + ` ticket ${ticketId}: parse=${JSON.stringify(parseResult)} campaignId=${grouped?.campaignId ?? 'null'} groupMethod=${grouped?.groupMethod ?? 'null'}` + ); + + if (grouped?.campaignId) { + try { + const result = await classifyCampaign(grouped.campaignId); + console.log(` classified: verdict=${result.verdict}`); + } catch (err) { + console.log(` classify failed: ${err instanceof Error ? err.message : err}`); + } + } +} + +async function main() { + console.log('=== Step 1: Reclassify 699415 campaign (stale pre-Phase-23 verdict) ==='); + const before = await postgresClient.query<{ verdict: string | null }>( + `SELECT verdict FROM classifications WHERE campaign_id = $1 ORDER BY created_at DESC LIMIT 1`, + [RECLASSIFY_CAMPAIGN_ID] + ); + console.log(` verdict before: ${before.rows[0]?.verdict ?? 'none'}`); + + const result = await classifyCampaign(RECLASSIFY_CAMPAIGN_ID); + console.log(` verdict after: ${result.verdict}`); + console.log(` reasons: ${JSON.stringify(result.reasons ?? [], null, 2)}`); + + console.log('\n=== Step 2: Analyze the 7 unparsed Seubert reports ==='); + for (const ticketId of UNPARSED_TICKET_IDS) { + await reanalyzeTicket(ticketId); + } + + console.log('\nDone.'); + process.exit(0); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/reclassify-699456.ts b/scripts/reclassify-699456.ts new file mode 100644 index 0000000..73cb6e3 --- /dev/null +++ b/scripts/reclassify-699456.ts @@ -0,0 +1,12 @@ +import { config } from 'dotenv'; +import { resolve } from 'path'; +config({ path: resolve('/opt/stacks/pulse/.env.local') }); + +import { classifyCampaign } from '../lib/services/campaign-classifier'; + +async function main() { + const result = await classifyCampaign('5cdb31a3-64c9-4237-8edc-7e317f4f614c'); + console.log(JSON.stringify(result, null, 2)); + process.exit(0); +} +main().catch((err) => { console.error(err); process.exit(1); }); diff --git a/scripts/verify-blast-radius-held-bug.ts b/scripts/verify-blast-radius-held-bug.ts new file mode 100644 index 0000000..bbf4619 --- /dev/null +++ b/scripts/verify-blast-radius-held-bug.ts @@ -0,0 +1,95 @@ +/** + * verify-blast-radius-held-bug.ts + * + * Ticket 699687 (Tomlinson, Seubert) shows Blast Radius: Matched 16, + * Delivered 1, Held 15, with btomlinson@seubert.com's per-recipient status + * shown as "held" — despite this being the accidental Guardian Protection + * report we already confirmed was cleanly delivered (spamScore 0, no + * detection) via Mimecast's own trace. Hypothesis: getHeldMessages() in + * lib/services/mimecast-blast-radius.ts is scoped ONLY by recipient (Mimecast's + * get-hold-message-list API has no sender/subject/date filter), so it pulls + * in EVERY message currently sitting in btomlinson's hold queue — unrelated + * to Guardian Protection — and the per-recipient merge logic lets any held + * row silently overwrite a same-recipient delivered row. This script + * independently verifies by calling the same two Mimecast calls the blast + * radius module makes and inspecting what's actually in the held queue. + * + * Usage: + * POSTGRES_HOST=localhost npx tsx scripts/verify-blast-radius-held-bug.ts + */ + +import { config } from 'dotenv'; +import { resolve } from 'path'; +config({ path: resolve('/opt/stacks/pulse/.env.local') }); + +import postgresClient from '../lib/services/postgres-client'; +import { getMimecastClientForTenant } from '../lib/services/mimecast-client'; + +const SEUBERT_COMPANY_ID = 29683407; +const SENDER = 'guardian_protection_do_not_reply@guardianprotection.com'; +const RECIPIENT = 'btomlinson@seubert.com'; +const SUBJECT = "Unexpected Activity for Tomlinson’s Home: The Front Door was left unlocked at 1:39 pm"; + +async function main() { + const tenantRow = await postgresClient.query<{ + client_id: string; + client_secret: string; + base_url: string | null; + account_name: string | null; + }>( + `SELECT client_id, client_secret, base_url, account_name FROM mimecast_tenants WHERE company_id = $1 AND enabled = true`, + [SEUBERT_COMPANY_ID] + ); + const tenant = tenantRow.rows[0]; + if (!tenant) { + console.log('No enabled Mimecast tenant for Seubert — cannot verify.'); + process.exit(1); + } + console.log(`Using Mimecast tenant: ${tenant.account_name}`); + + const client = getMimecastClientForTenant({ + client_id: tenant.client_id, + client_secret: tenant.client_secret, + base_url: tenant.base_url ?? undefined, + }); + + const now = new Date(); + const start = new Date(now.getTime() - 24 * 60 * 60 * 1000); + const startStr = start.toISOString().replace(/\.\d{3}Z$/, '+0000'); + const endStr = now.toISOString().replace(/\.\d{3}Z$/, '+0000'); + + console.log(`\n=== 1. searchDeliveredMessages (scoped: sender+recipient+subject+24h window) ===`); + const delivered = await client.searchDeliveredMessages({ + from: SENDER, + to: RECIPIENT, + subject: SUBJECT, + start: startStr, + end: endStr, + }); + console.log(`Delivered rows found: ${delivered.messages?.length ?? 0}`); + console.log(JSON.stringify(delivered.messages, null, 2)); + + console.log(`\n=== 2. getHeldMessages (scoped ONLY by recipient — no sender/subject/date filter possible) ===`); + const held = await client.getHeldMessages({ recipient: RECIPIENT }); + console.log(`Held rows found: ${held.messages.length} (totalCount reported: ${held.totalCount})`); + console.log( + JSON.stringify( + held.messages.map((m) => ({ from: m.from, subject: m.subject, dateReceived: m.dateReceived, reason: m.reason })), + null, + 2 + ) + ); + + const heldFromGuardianProtection = held.messages.filter((m) => + (m.from ?? '').toLowerCase().includes('guardianprotection.com') + ); + console.log(`\n=== 3. Of those held messages, how many are actually from Guardian Protection? ===`); + console.log(`${heldFromGuardianProtection.length} of ${held.messages.length}`); + + process.exit(0); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/verify-guardian-protection-mimecast.ts b/scripts/verify-guardian-protection-mimecast.ts new file mode 100644 index 0000000..ce16ea8 --- /dev/null +++ b/scripts/verify-guardian-protection-mimecast.ts @@ -0,0 +1,94 @@ +/** + * verify-guardian-protection-mimecast.ts + * + * Ticket 699687 (Tomlinson, Seubert) — an accidental phishing report of a + * genuine Guardian Protection (alarm.com-platform) home-security notification + * ("The Front Door was left unlocked"). The forwarded/reported copy's + * primary Authentication-Results shows spf=fail/dkim=fail/dmarc=fail (a + * known forwarding artifact — see authResultsOriginal), while the pre- + * forwarding authResultsOriginal shows all-pass. Before concluding Guardian + * Protection has bad email hygiene, independently verify against Mimecast's + * own real-time record of this exact message at Seubert's tenant. + * + * Usage: + * npx tsx scripts/verify-guardian-protection-mimecast.ts + */ + +import { config } from 'dotenv'; +import { resolve } from 'path'; +config({ path: resolve('/opt/stacks/pulse/.env.local') }); + +import postgresClient from '../lib/services/postgres-client'; +import { getMimecastClientForTenant } from '../lib/services/mimecast-client'; + +const SEUBERT_COMPANY_ID = 29683407; +const SENDER = 'guardian_protection_do_not_reply@guardianprotection.com'; +const RECIPIENT = 'btomlinson@seubert.com'; +const MESSAGE_ID = ''; + +async function main() { + const tenantRow = await postgresClient.query<{ + client_id: string; + client_secret: string; + base_url: string | null; + account_name: string | null; + }>( + `SELECT client_id, client_secret, base_url, account_name FROM mimecast_tenants WHERE company_id = $1 AND enabled = true`, + [SEUBERT_COMPANY_ID] + ); + const tenant = tenantRow.rows[0]; + if (!tenant) { + console.log('No enabled Mimecast tenant for Seubert — cannot verify.'); + process.exit(1); + } + console.log(`Using Mimecast tenant: ${tenant.account_name}`); + + const client = getMimecastClientForTenant({ + client_id: tenant.client_id, + client_secret: tenant.client_secret, + base_url: tenant.base_url ?? undefined, + }); + + const now = new Date(); + const start = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000); + const startStr = start.toISOString().replace(/\.\d{3}Z$/, '+0000'); + const endStr = now.toISOString().replace(/\.\d{3}Z$/, '+0000'); + + console.log(`\n=== 1. Exact sender+recipient match, Mimecast's own trace ===`); + const exact = await client.searchDeliveredMessages({ + from: SENDER, + to: RECIPIENT, + start: startStr, + end: endStr, + }); + console.log(JSON.stringify(exact, null, 2)); + + console.log(`\n=== 2. getMessageInfo by Message-ID ===`); + try { + const info = await client.getMessageInfo(MESSAGE_ID); + console.log(JSON.stringify(info, null, 2)); + } catch (err) { + console.log('getMessageInfo failed:', err instanceof Error ? err.message : err); + } + + console.log(`\n=== 3. Held messages for Tomlinson ===`); + const held = await client.getHeldMessages({ recipient: RECIPIENT }); + console.log(JSON.stringify(held, null, 2)); + + console.log(`\n=== 4. Recent threat events mentioning guardianprotection.com or alarm.com ===`); + const threats = await client.getThreatEvents({ pageSize: 500 }); + const relevant = threats.items.filter((t) => + (t.actorEmail ?? '').toLowerCase().includes('guardianprotection.com') || + (t.actorEmail ?? '').toLowerCase().includes('alarm.com') + ); + console.log(`Total threat events fetched: ${threats.items.length}`); + console.log(`Events involving guardianprotection.com/alarm.com: ${relevant.length}`); + console.log(JSON.stringify(relevant, null, 2)); + + process.exit(0); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/verify-held-date-scope.ts b/scripts/verify-held-date-scope.ts new file mode 100644 index 0000000..e37eb53 --- /dev/null +++ b/scripts/verify-held-date-scope.ts @@ -0,0 +1,56 @@ +import { config } from 'dotenv'; +import { resolve } from 'path'; +config({ path: resolve('/opt/stacks/pulse/.env.local') }); + +import postgresClient from '../lib/services/postgres-client'; +import { getMimecastClientForTenant } from '../lib/services/mimecast-client'; + +const SEUBERT_COMPANY_ID = 29683407; +const RECIPIENT = 'btomlinson@seubert.com'; + +async function main() { + const tenantRow = await postgresClient.query( + `SELECT client_id, client_secret, base_url, account_name FROM mimecast_tenants WHERE company_id = $1 AND enabled = true`, + [SEUBERT_COMPANY_ID] + ); + const tenant = tenantRow.rows[0]; + const client: any = getMimecastClientForTenant({ + client_id: tenant.client_id, + client_secret: tenant.client_secret, + base_url: tenant.base_url ?? undefined, + }); + + // Exact production window: createdAt (report creation, 2026-07-17T18:10:55Z) +/- 24h, end clamped to now. + const createdAt = new Date('2026-07-17T18:10:55.844Z'); + const start = new Date(createdAt.getTime() - 24 * 60 * 60 * 1000); + const end = new Date(Math.min(createdAt.getTime() + 24 * 60 * 60 * 1000, Date.now())); + const startStr = start.toISOString().replace(/\.\d{3}Z$/, '+0000'); + const endStr = end.toISOString().replace(/\.\d{3}Z$/, '+0000'); + + console.log(`Querying held messages for ${RECIPIENT} scoped to the EXACT production window: ${startStr}..${endStr}`); + + const body = { + data: [ + { + admin: true, + start: startStr, + end: endStr, + searchBy: { fieldName: 'recipient', value: RECIPIENT }, + }, + ], + }; + + const result = await client.request('POST', '/api/gateway/get-hold-message-list', body); + const msgs = result?.data ?? []; + console.log(`Rows returned: ${msgs.length}`); + console.log(JSON.stringify(msgs.map((m: any) => ({ + from: m.fromHeader?.emailAddress ?? m.from?.emailAddress, + subject: m.subject, + dateReceived: m.dateReceived, + reason: m.reason, + })), null, 2)); + + process.exit(0); +} + +main().catch((err) => { console.error(err); process.exit(1); }); diff --git a/scripts/verify-mansfield-mimecast.ts b/scripts/verify-mansfield-mimecast.ts new file mode 100644 index 0000000..3fae4c8 --- /dev/null +++ b/scripts/verify-mansfield-mimecast.ts @@ -0,0 +1,109 @@ +/** + * verify-mansfield-mimecast.ts + * + * Ticket 699451 (Richard Mansfield, Seubert) was manually forwarded by a + * technician, not reported via KnowBe4's Phish Alert button or Microsoft's + * "Report Message" add-in — so the detector never created a `reports` row, + * and everything we know about the sender ("eserver@it-support.care") comes + * from a human's free-text ticket description, not a parsed EML/header. + * + * it-support.care IS on the KNOWN_SIMULATION_SENDERS allowlist + * (lib/services/campaign-classifier.ts) for KnowBe4. Before trusting the + * ticket text at face value, independently verify against Mimecast's actual + * message-trace data for Seubert's tenant: does a message from that sender + * to Rich, with that subject, in that time window, actually exist and what + * does Mimecast say about it (delivered/held/rejected, real headers)? + * + * Usage: + * POSTGRES_HOST=localhost npx tsx scripts/verify-mansfield-mimecast.ts + */ + +import { config } from 'dotenv'; +import { resolve } from 'path'; +config({ path: resolve('/opt/stacks/pulse/.env.local') }); + +import postgresClient from '../lib/services/postgres-client'; +import { getMimecastClientForTenant } from '../lib/services/mimecast-client'; + +const SEUBERT_COMPANY_ID = 29683407; +const REPORTED_SENDER = 'eserver@it-support.care'; +const RECIPIENT = 'rmansfield@seubert.com'; +const SUBJECT = 'Internal Email Problems'; + +async function main() { + const tenantRow = await postgresClient.query<{ + client_id: string; + client_secret: string; + base_url: string | null; + account_name: string | null; + }>( + `SELECT client_id, client_secret, base_url, account_name FROM mimecast_tenants WHERE company_id = $1 AND enabled = true`, + [SEUBERT_COMPANY_ID] + ); + const tenant = tenantRow.rows[0]; + if (!tenant) { + console.log('No enabled Mimecast tenant for Seubert — cannot verify.'); + process.exit(1); + } + console.log(`Using Mimecast tenant: ${tenant.account_name}`); + + const client = getMimecastClientForTenant({ + client_id: tenant.client_id, + client_secret: tenant.client_secret, + base_url: tenant.base_url ?? undefined, + }); + + const now = new Date(); + const start = new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000); // 5 days back + const startStr = start.toISOString().replace(/\.\d{3}Z$/, '+0000'); + const endStr = now.toISOString().replace(/\.\d{3}Z$/, '+0000'); + + console.log(`\n=== 1. Exact sender+recipient+subject match ===`); + console.log(`from=${REPORTED_SENDER} to=${RECIPIENT} subject="${SUBJECT}" window=${startStr}..${endStr}`); + const exact = await client.searchDeliveredMessages({ + from: REPORTED_SENDER, + to: RECIPIENT, + subject: SUBJECT, + start: startStr, + end: endStr, + }); + console.log(JSON.stringify(exact, null, 2)); + + console.log(`\n=== 2. Sender domain only (it-support.care), any recipient at seubert.com ===`); + const domainOnly = await client.searchDeliveredMessages({ + from: 'it-support.care', + to: 'seubert.com', + start: startStr, + end: endStr, + }); + console.log(JSON.stringify(domainOnly, null, 2)); + + console.log(`\n=== 3. All mail TO Rich in the window (no sender filter) ===`); + const toRich = await client.searchDeliveredMessages({ + to: RECIPIENT, + subject: SUBJECT, + start: startStr, + end: endStr, + }); + console.log(JSON.stringify(toRich, null, 2)); + + console.log(`\n=== 4. Held messages for Rich ===`); + const held = await client.getHeldMessages({ recipient: RECIPIENT }); + console.log(JSON.stringify(held, null, 2)); + + console.log(`\n=== 5. Recent threat events on this tenant (last 500) ===`); + const threats = await client.getThreatEvents({ pageSize: 500 }); + const relevant = threats.items.filter( + (t) => (t.actorEmail ?? '').toLowerCase().includes('it-support.care') + ); + console.log(`Total threat events fetched: ${threats.items.length}`); + console.log(`Events involving it-support.care: ${relevant.length}`); + console.log(JSON.stringify(relevant, null, 2)); + + process.exit(0); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +});