Bug 1: clamp dateWindow.end to Math.min(createdAt + 24h, Date.now()) so a
freshly-detected campaign (<24h old primary report) never sends Mimecast a
future end-date -- previously rejected as err_track_and_trace_invalid_end_date
and swallowed internally as a false-clean zero-count result.
Bug 2 (D-05): add company_id to the reports SELECT and, when the reporting
company has its own enabled mimecast_tenants row, resolve a tenant-scoped
client via getMimecastClientForTenant() and thread it into getBlastRadius as
{ client, cacheScope: companyId }. Falls back to the global env-configured
client when no company-specific tenant is registered.
- app/phishing/tickets/[ticketId]/page.tsx: resolves ticket->campaign via
the plan-02 resolver route, drives a loading/not-triaged/ungrouped/ready/
error state machine, branches ready into grouped-but-unclassified
(Classify CTA, no ClassificationCard/ActionAreaCard) vs. classified (all
four cards with explicit props), refetches after every action (D-04),
session-only auth (no token/query-param scheme)
- app/api/phishing/reports/[report_id]/route.ts (new, additive): thin
report-scoped evidence + fresh blast-radius lookup for the D-08
ungrouped-report state, which has no campaignId to key the existing
campaign-detail route on — added as a Rule 2 dependency since the plan's
own D-08 truth ("standalone-report notice + evidence") has no other data
source
- Alias campaigns table as c, add correlated subquery for the earliest
linked report's ticket_id so the list page can navigate a row click
straight to /phishing/tickets/{firstReportTicketId}
- Additive only: count query, limit/offset, requirePermission gate, and
the { items, total, limit, offset } envelope all unchanged
- New GET /api/phishing/tickets/{ticket_id}/campaign wraps
resolveTicketToCampaign() from plan 22-01
- requirePermission('phishing','read') gate, Number.isFinite param
validation, D-07: found:false at 200 (not 404) for untriaged tickets
Structural twin of the classify route: requirePermission('phishing',
'analyze') gate, UUID guard, campaign-exists 404 check, delegates to
generateAndPostTriageNote and returns its result verbatim (note text +
per-ticket posted/error status, D-06). No audit-event write — deferred
per 21-CONTEXT.md.
- POST /approve: phishing:approve gated, validates actions array (D-03),
delegates to approveRemediationActions with actor from session
- POST /remediate: phishing:remediate gated, delegates to
remediateApprovedActions (idempotent completion, REMED-03/04)
- Both UUID-guard the campaign id and map RemediationValidationError->400,
RemediationConflictError->409
- requirePermission('phishing','analyze') early-return (same action as /analyze, Phase 18 D-06)
- UUID_RE guard on campaign id before any DB query (T-19-05)
- 404 when campaign id is well-formed but not found
- delegates to classifyCampaign(id) from lib/services/campaign-classifier.ts (Plan 01), returns flat ClassifyResult payload
The count query reused statusFilter (built with $3 against the list query's
3-element params array) but only passed a 1-element params array, causing a
Postgres bind-parameter mismatch (500) on any `?status=` filtered request.
Pre-existing since 18-03; surfaced by the 18-04 gap-closure code re-review.
Gives the count query its own independent param array/placeholder numbering.
- Parse limit once with Number.isFinite instead of `|| 50`, so an explicit
limit=0 is honored instead of silently replaced by the default
- Math.max/min clamps to [0, 200], preventing a negative limit from reaching
the SQL LIMIT clause and raising an unhandled 500
- requirePermission('phishing','read') gate (ACCESS-01)
- UUID-validated id (400 on malformed), 404 when campaign absent
- bulk-fetch reports/messages/indicators via ANY($1::uuid[]) keyed by parent id array (device-link-conflicts pattern)
- requesterEmail derived via reports.requester_contact_id -> contacts join (campaigns has no recipients column)
- messages.subject pulled from headers->>'subject' JSONB (no subject column)
- classifications included in shape (Phase 19 stub, expected empty)