Commit graph

16 commits

Author SHA1 Message Date
97804f2e5b feat(260717-v6c): add POST /api/phishing/campaigns/[id]/mark-accidental-report route
Mirrors mark-false-positive route exactly (requirePermission phishing/approve,
UUID validation, optional reason body, campaign-existence check, 409/400/500
error mapping) but calls markCampaignAccidentalReport and returns the richer
result including notePosted/noteError.
2026-07-17 22:33:09 -04:00
9951e53832 fix(260716-n46): clamp blast-radius date window and resolve per-company Mimecast tenant
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.
2026-07-16 16:47:19 -04:00
3761312f93 feat(22-06): ticket-scoped LiveLink review page (REVIEW-01,05,06)
- 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
2026-07-16 14:58:39 -04:00
c70b30a000 feat(22-02): add firstReportTicketId to campaigns list route
- 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
2026-07-16 14:43:43 -04:00
9e83ec09ee feat(22-02): extend campaign detail route with evidence, timeline, blast radius
- Widen messages query to include headers/urls/attachments/body_preview
- Widen classifications query to include reasons/recommendedActions/
  requiresApproval
- Add remediationActions (with completedAt derived from audit_events
  payload.actionId, no completion-timestamp column exists) and
  auditEvents to the response
- Add fresh per-request blastRadius via getBlastRadius(), sender/
  recipient/subject derivation copied from campaign-classifier.ts's
  gatherCampaignEvidence (not triage-note-service's empty-string call)
- Add mergeTimeline()-derived chronological timeline
- All additive — existing fields, UUID_RE guard, and auth gate unchanged
2026-07-16 14:42:56 -04:00
ca63910562 feat(22-02): add ticket->campaign resolver route
- 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
2026-07-16 14:41:06 -04:00
e3a9cb5191 feat(21-02): add POST /api/phishing/campaigns/[id]/triage-note route
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.
2026-07-16 12:14:23 -04:00
1a126078d7 feat(20-02): add mark-false-positive route and classify audit event
- POST /mark-false-positive: phishing:approve gated (D-04 elevated tier),
  optional reason body, delegates to markCampaignFalsePositive, maps
  RemediationConflictError->409 (already remediated) and
  RemediationValidationError->400
- classify route now writes a 'campaign_classified' audit event after a
  successful classification, completing REMED-06's four-action audit
  coverage (classify/approve/remediate/mark-false-positive)
2026-07-16 10:44:30 -04:00
65c4253f98 feat(20-02): add approve and remediate routes for phishing campaigns
- 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
2026-07-16 10:43:53 -04:00
3e8d5b83c9 feat(19-02): add POST /api/phishing/campaigns/[id]/classify route
- 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
2026-07-16 08:26:20 -04:00
650f9b8100 fix(18): decouple campaigns count-query params from list-query placeholder numbering
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.
2026-07-15 22:35:47 -04:00
abe3d4b900 fix(18-04): clamp campaigns list limit param to [0, 200] (WR-02)
- 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
2026-07-15 22:25:29 -04:00
701fea04cc chore: merge executor worktree (worktree-agent-a985b30606e37e1d0) 2026-07-15 19:33:32 -04:00
959907d63b feat(18-03): add GET /api/phishing/campaigns/[id] nested detail
- 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)
2026-07-15 19:31:35 -04:00
c852cfee13 feat(18-03): add GET /api/phishing/campaigns paginated list
- requirePermission('phishing','read') gate (ACCESS-01)
- limit/offset clamped, optional status filter via parameterized $n placeholder (never string-interpolated)
- camelCase response { items, total, limit, offset }
2026-07-15 19:31:19 -04:00
de013e6ec1 feat(18-02): add POST /api/phishing/tickets/[ticket_id]/analyze route
- Orchestrates detectPhishingTicket -> parseAndStoreMessage -> groupReportIntoCampaign
- requirePermission('phishing','analyze') gate first-line (D-06, 401/403)
- Validates ticket_id numeric (400), missing ticket (404), non-phishing ticket (400)
- No skipIfAlreadyGrouped (D-08) — always re-runs grouping on demand
2026-07-15 19:30:46 -04:00