- searchDeliveredMessages now called with from+subject+start+end only
(no `to`) so it returns every delivered/rejected message matching the
campaign across all recipients, not just the reporter's mailbox
- getHeldMessages now called with start+end only (no `recipient`) —
domainsMatch() post-filter is the sole scoping mechanism for held rows
- Updated inline comments to document the tenant-wide fan-out and the
per-recipient merge behavior it now produces
- Add migration 101: guarded UPDATE moving mimecast-sync from 0 2 * * * to
45 4 * * * (minute 45 is unused by any other schedule row)
- Guarded on the stale cron value so it's a no-op if already moved and won't
clobber an admin's manual schedule change
- Applied the same UPDATE directly to the live pulse-postgres container
(migrations only auto-apply on first volume boot, per CLAUDE.md)
- Eliminates the 3-way 2 AM collision with qbo-sync-2am and veeam-full
- Extend ScheduleConfig.sync_type union with 'mimecast-sync'
- Add mimecast-sync branch calling runMimecastIncrementalSync() behind
isMimecastConfigured(), mirroring the engagement/zoom configured-gate pattern
- Add qbo branch calling getQboSyncService().incrementalSync('scheduled')
behind an integration_settings disabled check, mirroring the pax8-daily
disable-check pattern
- Both branches previously fell through to the generic Autotask fullSync()
catch-all, which also contended for the SyncService singleton mutex
Records the plan, execution summary, and STATE.md quick-task log entry
for the QBO_INTEGRATION_HANDOFF.md doc committed in ea8a36b.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6RuWdiUiXrPK6FLBHjtpY
Documents Pulse's QuickBooks Online integration (OAuth2 authorization-code
flow, token storage/refresh, sandbox vs production API base URLs, scopes,
minor version, and gotchas learned from the AR reconciliation/soft-delete
work) so a new app's team can build their own QBO connection without
access to the Pulse codebase.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6RuWdiUiXrPK6FLBHjtpY
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