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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6RuWdiUiXrPK6FLBHjtpY
- blast-radius: getHeldMessages called with same start/end window as
searchDeliveredMessages
- blast-radius: unrelated-sender held row excluded from held/matched and
perRecipient; matching-sender held row still counts and overrides
- client: getHeldMessages threads start/end into POST body data[0] when
provided, omits them when not
- getHeldMessages() accepts optional start/end, threaded into data[0] as
siblings of admin/searchBy (backward compatible when omitted; 403
fallback body inherits them automatically via the existing spread)
- getBlastRadius() passes the same startStr/endStr window already
computed for searchDeliveredMessages into getHeldMessages()
- Added domainsMatch() sender-relevance guard: held rows whose sender
domain doesn't match input.sender (exact-or-proper-subdomain) are
filtered out before counting/merging, so unrelated same-window holds
never inflate held/matched or override a delivered recipient
- Looks up campaign_id live per ticket (699419/699421/699422/699433/
699435/699456), no hardcoded UUIDs
- Reads before verdict, calls classifyCampaign (D-02 append-only), prints
before -> after verdict + confidence per ticket
- Dedupes shared campaign_ids so a shared campaign isn't classified twice
- Depends on Task 1's allowlist fix already being committed
- parseAndStoreMessage (Defect 3): short-circuit with
{ stored: false, reason: 'already-parsed' } when a messages row already
exists for the report, before any Autotask attachment fetch
- webhook-service (Defect 2): new retryPhishingParseOnUpdate wired into
ticket.update fire-and-forget path; retries the missing-EML parse for a
flagged, unparsed, auto_parse-gated report — no new cron/polling, reuses
existing update traffic, safe to fire repeatedly thanks to the new
idempotency guard
- Adjust eml-service test mock default so the new leading existence-check
query doesn't short-circuit existing happy-path tests; add new test for
the already-parsed short-circuit
Archived to .planning/milestones/v3.0-REQUIREMENTS.md. Fresh REQUIREMENTS.md
gets created when the next milestone's requirements are defined.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6RuWdiUiXrPK6FLBHjtpY
23-06-REVIEW.md found two real defects in the just-merged idempotency fix:
- CR-01: autoPostAcknowledgment's audit payload omitted actionId, which the
campaign-detail API requires to derive completedAt — every auto-posted
acknowledge_user row rendered a null completion date in the Action Area UI.
- CR-02: the manual approve/remediate path had no server-side guard against
re-approving acknowledge_user for a campaign that already got auto-posted —
only a client-side UI check prevented the exact duplicate-note bug 23-06
was chartered to close, reachable via a direct API call.
Fixes both: capture RETURNING id from the insert and include it in the audit
payload; add an existence check in approveRemediationActions that rejects
acknowledge_user when already posted for the campaign.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6RuWdiUiXrPK6FLBHjtpY
Replace the unguarded generateAndPostAcknowledgment(campaignId) direct
call in runGatedPhishingStages' auto_report branch with the idempotent
autoPostAcknowledgment(campaignId, 'system:auto_report'). Closes CR-01
(23-REVIEW.md) / Truth #18 (23-VERIFICATION.md): a repeat ticket-create
webhook joining an already-acknowledged USER_AWARENESS campaign now
finds the persisted remediation_actions row and skips the re-post
instead of re-sending the customer-visible thank-you note on every
additional report.
- New autoPostAcknowledgment(campaignId, actor) mirrors the VERIFIED
remediateApprovedActions shape: campaign row lock, idempotency check
against an existing acknowledge_user remediation_actions row, insert +
audit inside one transaction, note post after commit (non-fatal on
failure)
- Closes CR-01/WR-01: gives the auto_report webhook path a persisted
record so a repeat ticket-create webhook joining an already-acked
campaign does not re-insert/re-audit/re-post
- Adds Test A/B/C in remediation-service.test.ts covering first-pass
insert, idempotent skip, and non-fatal note-post failure
- Adds 23-04-SUMMARY.md documenting Task 1 (page) and Task 2 (index tile)
- Records that Task 1's human-verify checkpoint was approved via a
temporary production container rebuild rather than local npm run dev
- Company table with search + type filter (cloned from client-scope pattern)
- Three independent Switch toggles per row: auto-parse, auto-classify, auto-report
- toggle() PATCHes /api/admin/phishing-automation/{companyId} with all three current flags
- Helper caption clarifies stage dependency (informational, not enforced)
- triggerPhishingDetection now captures groupReportIntoCampaign's result and,
when a campaignId exists, calls new runGatedPhishingStages
- runGatedPhishingStages reads the per-company automation gate and
conditionally runs parseAndStoreMessage, classifyCampaign, and (only for
USER_AWARENESS verdicts) generateAndPostAcknowledgment
- each stage isolated in its own try/catch (T-23-09); detection + grouping
remain unconditional (D-07); auto_report never posts any other action
(D-04, T-23-08)