chore: merge executor worktree (worktree-agent-a5b1c5010c37561a1)

This commit is contained in:
lorentz 2026-07-16 14:32:03 -04:00
commit 2c70747822
8 changed files with 531 additions and 0 deletions

View file

@ -0,0 +1,126 @@
---
phase: 22-approval-ui-livelink-addressable-campaign-review-and-approve
plan: 01
subsystem: api
tags: [vitest, tdd, postgres, phishing-triage, pure-functions]
# Dependency graph
requires:
- phase: 19-phishing-triage-classification-engine
provides: campaign-classifier.ts's mapVerdictToActions 7-action vocabulary and postgresClient/vi.mock testing idiom
- phase: 20-remediation-approval-audit-safety
provides: remediation-service.ts's ApproveActionInput shape and phishing-audit.ts's canonical event_type values
provides:
- resolveTicketToCampaign(ticketId) — pure ticket->campaign lookup service
- deriveDefaultParams(actionType, evidence) — 7-action-type default-param derivation
- mergeTimeline(reports, classifications, auditEvents) — chronological 3-source merge
affects: [22-02-ticket-campaign-route, 22-04-action-area-card, 22-05-timeline-card]
# Tech tracking
tech-stack:
added: []
patterns:
- "Pure lib/services/*.ts extraction for route/component logic that needs vitest coverage (test.include is lib/**/*.test.ts only)"
- "vi.mock('./postgres-client', () => ({ postgresClient: { query: ... } })) factory-mock idiom (mirrors campaign-classifier.test.ts)"
key-files:
created:
- lib/services/phishing-ticket-resolver.ts
- lib/services/phishing-ticket-resolver.test.ts
- lib/services/remediation-default-params.ts
- lib/services/remediation-default-params.test.ts
- lib/services/phishing-timeline.ts
- lib/services/phishing-timeline.test.ts
modified: []
key-decisions:
- "Used named import `{ postgresClient }` from './postgres-client' (matches the sibling files' actual convention — campaign-classifier.ts, remediation-service.ts, phishing-audit.ts — and the PATTERNS.md code snippet), not the plan action text's parenthetical 'default import' gloss, since the vi.mock factory only provides a named `postgresClient` export and a default-import call site would not be mocked."
- "mergeTimeline's report/classification/audit source shapes use camelCase `createdAt` fields (not snake_case `created_at`) since the read_first note specifies these consume the already-transformed camelCase API response shapes from app/api/phishing/campaigns/[id]/route.ts."
patterns-established:
- "Pattern: exhaustive switch with `default: return {}` for extensible action-type-keyed pure derivations (mirrors mapVerdictToActions)"
- "Pattern: discriminated union + fixed KIND_PRIORITY map for stable multi-source chronological merges"
requirements-completed: [REVIEW-01, REVIEW-02, REVIEW-04]
# Metrics
duration: 12min
completed: 2026-07-16
---
# Phase 22 Plan 01: Wave 0 Pure-Logic Extraction Summary
**Three pure `lib/services/*.ts` modules (ticket->campaign resolver, 7-action-type default-param derivation, 3-source timeline merge) extracted and unit-tested under vitest, unblocking the route/component work in plans 02/04/05.**
## Performance
- **Duration:** 12 min
- **Started:** 2026-07-16T18:17:00Z
- **Completed:** 2026-07-16T18:29:00Z
- **Tasks:** 3 completed
- **Files modified:** 6 (all new)
## Accomplishments
- `resolveTicketToCampaign(ticketId)` covers all three resolution states (no report / ungrouped / grouped) via a single parameterized `reports` lookup
- `deriveDefaultParams(actionType, evidence)` implements the exact 7-action-type default-params table from the UI-SPEC, with a safe `{}` fallback for unknown/future action types
- `mergeTimeline(reports, classifications, auditEvents)` produces a single ascending-chronological array with a discriminated `TimelineEntry` union and a deterministic tie-break order
- All 3 modules are pure or DB-only (no `NextResponse`/`requirePermission` in any of them) — route-free per the plan's design intent
## Task Commits
Each task was committed via full TDD RED/GREEN pairs:
1. **Task 1: phishing-ticket-resolver.ts + test**`20b1e1b` (test), `e619321` (feat)
2. **Task 2: remediation-default-params.ts + test**`c64a905` (test), `86cffc4` (feat)
3. **Task 3: phishing-timeline.ts + test (server-merge)**`37f6657` (test), `d30a49f` (feat)
**Plan metadata:** (this commit)
## Files Created/Modified
- `lib/services/phishing-ticket-resolver.ts` - `resolveTicketToCampaign(ticketId)`, parameterized `reports` lookup
- `lib/services/phishing-ticket-resolver.test.ts` - 4 tests covering the 3 resolution states + query-shape assertion
- `lib/services/remediation-default-params.ts` - `deriveDefaultParams(actionType, evidence)`, exhaustive switch over 7 action types + fallback
- `lib/services/remediation-default-params.test.ts` - 9 tests covering all 7 known types, unknown fallback, and null-evidence defaults
- `lib/services/phishing-timeline.ts` - `mergeTimeline(reports, classifications, auditEvents)`, `TimelineEntry` discriminated union
- `lib/services/phishing-timeline.test.ts` - 4 tests covering sort order, length invariant, per-variant fields, and tie-break stability
## Decisions Made
- Named import for `postgresClient` (not the plan text's literal "default import" parenthetical) — see `key-decisions` above; verified against all 3 sibling files (`campaign-classifier.ts`, `remediation-service.ts`, `phishing-audit.ts`) and the working `vi.mock` test pattern.
- `mergeTimeline` inputs are camelCase (`createdAt`, `reportId`, `ticketNumber`, `companyName`, `verdict`, `confidence`, `eventType`, `actor`, `payload`) since this function consumes the campaign-detail route's already-transformed response shape, not raw DB rows.
## Deviations from Plan
None - plan executed exactly as written, with one clarifying correction to an internal inconsistency in the plan text (see Decisions Made: named vs. default import) resolved in favor of the working, testable convention already established in this phase's sibling files.
## Issues Encountered
None - all three RED phases confirmed failing before implementation (module-not-found), all three GREEN phases passed on first implementation attempt.
## TDD Gate Compliance
All three tasks followed the full RED -> GREEN cycle with separate commits:
- Task 1: `test(22-01)` at `20b1e1b` -> `feat(22-01)` at `e619321`
- Task 2: `test(22-01)` at `c64a905` -> `feat(22-01)` at `86cffc4`
- Task 3: `test(22-01)` at `37f6657` -> `feat(22-01)` at `d30a49f`
No REFACTOR commits were needed — each GREEN implementation passed cleanly without follow-up cleanup.
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
- Plan 02 (ticket->campaign resolver route) can now call `resolveTicketToCampaign` directly — the route becomes a thin `requirePermission` + param-parse + call wrapper per PATTERNS.md.
- Plans 04/05 (Action Area Card, Timeline Card) have a tested `deriveDefaultParams`/`mergeTimeline` to call from their respective routes/components (or the extended `campaigns/[id]` route, per the server-merge decision).
- Full test suite: 411/413 passing. The 2 failures are pre-existing, unrelated `lib/services/analyzer/itglue-search.test.ts` failures (untouched by this plan) — logged in `.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/deferred-items.md`, out of scope per the plan's file list.
- `npx tsc --noEmit --pretty` is clean.
---
*Phase: 22-approval-ui-livelink-addressable-campaign-review-and-approve*
*Completed: 2026-07-16*
## Self-Check: PASSED
All 6 created files verified present on disk; all 6 task commit hashes
(`20b1e1b`, `e619321`, `c64a905`, `86cffc4`, `37f6657`, `d30a49f`) verified
present in `git log --oneline --all`.

View file

@ -0,0 +1,16 @@
# Deferred Items — Phase 22
Pre-existing failures/warnings discovered during execution but out of scope
for the current plan (not touched by this plan's files, not caused by this
plan's changes).
## Plan 01
- `lib/services/analyzer/itglue-search.test.ts` — 2 pre-existing failing
tests (`itglueSearch > tolerates per-call failures ...`, doc count
mismatches). Unrelated to phishing-ticket-resolver /
remediation-default-params / phishing-timeline (this plan's files). Not
modified by any commit in this plan. Confirmed present before this plan's
changes (file untouched in `git diff`). Full-suite run: `2 failed | 411
passed (413)` with only these 2 pre-existing failures — all other tests,
including the 3 new files added by this plan, are green.

View file

@ -0,0 +1,76 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock postgresClient BEFORE importing the module under test — mirrors
// campaign-classifier.test.ts's vi.mock() factory-mocking discipline.
const queryMock = vi.fn();
vi.mock('./postgres-client', () => ({
postgresClient: {
query: (...args: unknown[]) => queryMock(...args),
},
}));
// eslint-disable-next-line import/first -- imported after vi.mock hoisting
import { resolveTicketToCampaign } from './phishing-ticket-resolver';
describe('resolveTicketToCampaign', () => {
beforeEach(() => {
queryMock.mockReset();
});
it('returns { found: false } when no reports row exists for the ticket id', async () => {
queryMock.mockResolvedValue({ rows: [], rowCount: 0 });
const result = await resolveTicketToCampaign(999);
expect(result).toEqual({ found: false });
});
it('returns { found: true, campaignId: null } for an ungrouped report', async () => {
queryMock.mockResolvedValue({
rows: [{ id: 'report-1', campaign_id: null, ticket_number: 'T20260716.0001' }],
rowCount: 1,
});
const result = await resolveTicketToCampaign(123);
expect(result).toEqual({
found: true,
reportId: 'report-1',
campaignId: null,
ticketNumber: 'T20260716.0001',
});
});
it('returns { found: true, reportId, campaignId, ticketNumber } for a grouped report', async () => {
queryMock.mockResolvedValue({
rows: [
{
id: 'report-2',
campaign_id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
ticket_number: 'T20260716.0002',
},
],
rowCount: 1,
});
const result = await resolveTicketToCampaign(456);
expect(result).toEqual({
found: true,
reportId: 'report-2',
campaignId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
ticketNumber: 'T20260716.0002',
});
});
it('queries reports filtered by ticket_id with a parameterized query', async () => {
queryMock.mockResolvedValue({ rows: [], rowCount: 0 });
await resolveTicketToCampaign(1);
expect(queryMock).toHaveBeenCalledWith(
expect.stringContaining('FROM reports WHERE ticket_id = $1'),
[1]
);
});
});

View file

@ -0,0 +1,48 @@
/**
* Phishing Ticket -> Campaign Resolver (Phase 22, Wave 0)
*
* Pure lookup service: given an Autotask ticket id, finds the linked
* `reports` row (if any) and reports back its campaign linkage. No auth,
* no param parsing that lives in the route (plan 02, GET
* /api/phishing/tickets/[ticket_id]/campaign). Extracted as its own module
* so it is unit-testable under vitest (whose `test.include` is
* `lib/**\/*.test.ts` only `app/**` route files have no coverage).
*/
import { postgresClient } from './postgres-client';
export interface TicketCampaignResolution {
found: boolean;
reportId?: string;
campaignId?: string | null;
ticketNumber?: string | null;
}
interface ReportLookupRow {
id: string;
campaign_id: string | null;
ticket_number: string | null;
}
/**
* Looks up the `reports` row for a given ticket id (tickets.id / reports.ticket_id).
* - No row: `{ found: false }`
* - Row with `campaign_id = null`: `{ found: true, reportId, campaignId: null, ticketNumber }` ungrouped report
* - Row with a `campaign_id`: `{ found: true, reportId, campaignId, ticketNumber }` grouped into a campaign
*/
export async function resolveTicketToCampaign(ticketId: number): Promise<TicketCampaignResolution> {
const res = await postgresClient.query<ReportLookupRow>(
`SELECT id::text, campaign_id::text, ticket_number FROM reports WHERE ticket_id = $1`,
[ticketId]
);
const report = res.rows[0];
if (!report) {
return { found: false };
}
return {
found: true,
reportId: report.id,
campaignId: report.campaign_id,
ticketNumber: report.ticket_number,
};
}

View file

@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest';
import { mergeTimeline, type TimelineEntry } from './phishing-timeline';
describe('mergeTimeline', () => {
const reports = [
{ createdAt: '2026-07-15T00:00:00.000Z', reportId: 'report-1', ticketNumber: 'T-0001', companyName: 'Acme' },
{ createdAt: '2026-07-15T04:00:00.000Z', reportId: 'report-2', ticketNumber: 'T-0002', companyName: 'Acme' },
];
const classifications = [
{ createdAt: '2026-07-15T02:00:00.000Z', verdict: 'THREAT', confidence: '85' },
];
const auditEvents = [
{ createdAt: '2026-07-15T01:00:00.000Z', eventType: 'remediation_approved', actor: 'lorentz@wulfconsulting.com', payload: { actions: ['block_sender'] } },
{ createdAt: '2026-07-15T05:00:00.000Z', eventType: 'remediation_completed', actor: null, payload: {} },
];
it('returns a single array sorted ascending by `at` across all 3 sources', () => {
const result = mergeTimeline(reports, classifications, auditEvents);
expect(result).toHaveLength(5);
const timestamps = result.map((entry) => new Date(entry.at).getTime());
for (let i = 1; i < timestamps.length; i++) {
expect(timestamps[i]).toBeGreaterThanOrEqual(timestamps[i - 1]);
}
});
it('output length equals the sum of the three input array lengths', () => {
const result = mergeTimeline(reports, classifications, auditEvents);
expect(result).toHaveLength(reports.length + classifications.length + auditEvents.length);
});
it('carries a discriminant `kind` and the source-specific fields for each entry', () => {
const result = mergeTimeline(reports, classifications, auditEvents);
const reportEntry = result.find((e) => e.kind === 'report' && e.reportId === 'report-1');
expect(reportEntry).toMatchObject({ kind: 'report', reportId: 'report-1', ticketNumber: 'T-0001', companyName: 'Acme' });
const classificationEntry = result.find((e) => e.kind === 'classification');
expect(classificationEntry).toMatchObject({ kind: 'classification', verdict: 'THREAT', confidence: '85' });
const auditEntry = result.find((e) => e.kind === 'audit' && e.eventType === 'remediation_approved');
expect(auditEntry).toMatchObject({
kind: 'audit',
eventType: 'remediation_approved',
actor: 'lorentz@wulfconsulting.com',
payload: { actions: ['block_sender'] },
});
});
it('preserves a stable tie-break order (report, then classification, then audit) on identical timestamps and does not throw', () => {
const tiedAt = '2026-07-15T10:00:00.000Z';
const tiedReports = [{ createdAt: tiedAt, reportId: 'r', ticketNumber: 'T', companyName: 'C' }];
const tiedClassifications = [{ createdAt: tiedAt, verdict: 'SPAM', confidence: '50' }];
const tiedAudit = [{ createdAt: tiedAt, eventType: 'campaign_marked_false_positive', actor: null, payload: {} }];
let result: TimelineEntry[] = [];
expect(() => {
result = mergeTimeline(tiedReports, tiedClassifications, tiedAudit);
}).not.toThrow();
expect(result.map((e) => e.kind)).toEqual(['report', 'classification', 'audit']);
});
});

View file

@ -0,0 +1,85 @@
/**
* Phishing Campaign Timeline Merge (Phase 22, Wave 0, REVIEW-02)
*
* Pure transform: merges the three already-camelCased sources consumed by
* `TimelineCard` (reports, classifications, audit_events see
* app/api/phishing/campaigns/[id]/route.ts's response shape and
* lib/services/phishing-audit.ts's canonical event_type values) into a
* single ascending-by-timestamp array. Server-merge approach chosen per
* 22-RESEARCH.md Alternatives Considered. No DB/fetch imports pure
* function, takes already-fetched row arrays.
*/
export type TimelineEntry =
| { kind: 'report'; at: string; reportId: string; ticketNumber: string | null; companyName: string | null }
| { kind: 'classification'; at: string; verdict: string; confidence: string | null }
| { kind: 'audit'; at: string; eventType: string; actor: string | null; payload: unknown };
interface ReportTimelineSource {
createdAt: string;
reportId: string;
ticketNumber: string | null;
companyName: string | null;
}
interface ClassificationTimelineSource {
createdAt: string;
verdict: string;
confidence: string | null;
}
interface AuditTimelineSource {
createdAt: string;
eventType: string;
actor: string | null;
payload: unknown;
}
/** Fixed tie-break priority when two entries share an identical `at` timestamp. */
const KIND_PRIORITY: Record<TimelineEntry['kind'], number> = {
report: 0,
classification: 1,
audit: 2,
};
/**
* Maps each of the three sources into its `TimelineEntry` variant,
* concatenates, and sorts ascending by `at` (oldest first matches the
* existing `ORDER BY created_at ASC` convention). Ties on identical
* timestamps fall back to a fixed kind priority (report < classification <
* audit) for a stable, deterministic order.
*/
export function mergeTimeline(
reports: ReportTimelineSource[],
classifications: ClassificationTimelineSource[],
auditEvents: AuditTimelineSource[]
): TimelineEntry[] {
const entries: TimelineEntry[] = [
...reports.map<TimelineEntry>((report) => ({
kind: 'report',
at: report.createdAt,
reportId: report.reportId,
ticketNumber: report.ticketNumber,
companyName: report.companyName,
})),
...classifications.map<TimelineEntry>((classification) => ({
kind: 'classification',
at: classification.createdAt,
verdict: classification.verdict,
confidence: classification.confidence,
})),
...auditEvents.map<TimelineEntry>((event) => ({
kind: 'audit',
at: event.createdAt,
eventType: event.eventType,
actor: event.actor,
payload: event.payload,
})),
];
return entries.sort((a, b) => {
const diff = new Date(a.at).getTime() - new Date(b.at).getTime();
if (diff !== 0) return diff;
return KIND_PRIORITY[a.kind] - KIND_PRIORITY[b.kind];
});
}

View file

@ -0,0 +1,75 @@
import { describe, it, expect } from 'vitest';
import { deriveDefaultParams, type DefaultParamEvidence } from './remediation-default-params';
const filledEvidence: DefaultParamEvidence = {
requesterEmail: 'requester@wulfconsulting.test',
senderEmail: 'attacker@evil.test',
senderDomain: 'evil.test',
messageId: 'message-123',
};
const emptyEvidence: DefaultParamEvidence = {
requesterEmail: null,
senderEmail: null,
senderDomain: null,
messageId: null,
};
describe('deriveDefaultParams', () => {
it('returns {} for no_action', () => {
expect(deriveDefaultParams('no_action', filledEvidence)).toEqual({});
});
it('returns recipientEmail + empty message for warn_user, using requesterEmail', () => {
expect(deriveDefaultParams('warn_user', filledEvidence)).toEqual({
recipientEmail: 'requester@wulfconsulting.test',
message: '',
});
});
it('returns senderEmail + senderDomain for block_sender', () => {
expect(deriveDefaultParams('block_sender', filledEvidence)).toEqual({
senderEmail: 'attacker@evil.test',
senderDomain: 'evil.test',
});
});
it('returns messageId + empty mailboxes for purge_message', () => {
expect(deriveDefaultParams('purge_message', filledEvidence)).toEqual({
messageId: 'message-123',
mailboxes: [],
});
});
it('returns userPrincipalName (from requesterEmail) for reset_password', () => {
expect(deriveDefaultParams('reset_password', filledEvidence)).toEqual({
userPrincipalName: 'requester@wulfconsulting.test',
});
});
it('returns empty deviceId for isolate_endpoint', () => {
expect(deriveDefaultParams('isolate_endpoint', filledEvidence)).toEqual({ deviceId: '' });
});
it('returns userPrincipalName + empty ruleName for disable_forwarding_rule', () => {
expect(deriveDefaultParams('disable_forwarding_rule', filledEvidence)).toEqual({
userPrincipalName: 'requester@wulfconsulting.test',
ruleName: '',
});
});
it('returns {} for an unknown/future action type', () => {
expect(deriveDefaultParams('unknown_future_type', filledEvidence)).toEqual({});
});
it('falls back to empty-string defaults for warn_user/block_sender/reset_password/disable_forwarding_rule when evidence fields are null', () => {
expect(deriveDefaultParams('warn_user', emptyEvidence)).toEqual({ recipientEmail: '', message: '' });
expect(deriveDefaultParams('block_sender', emptyEvidence)).toEqual({ senderEmail: '', senderDomain: '' });
expect(deriveDefaultParams('purge_message', emptyEvidence)).toEqual({ messageId: '', mailboxes: [] });
expect(deriveDefaultParams('reset_password', emptyEvidence)).toEqual({ userPrincipalName: '' });
expect(deriveDefaultParams('disable_forwarding_rule', emptyEvidence)).toEqual({
userPrincipalName: '',
ruleName: '',
});
});
});

View file

@ -0,0 +1,42 @@
/**
* Remediation Default-Params Derivation (Phase 22, Wave 0)
*
* Pure transform: given an action type (from campaign-classifier.ts's
* `mapVerdictToActions` vocabulary) and bounded evidence fields, returns the
* default `params` object an operator sees pre-filled in the Action Area
* before approving (feeds `ApproveActionInput.params` in
* lib/services/remediation-service.ts). No DB/fetch imports pure function.
*/
export interface DefaultParamEvidence {
requesterEmail: string | null;
senderEmail: string | null;
senderDomain: string | null;
messageId: string | null;
}
/**
* Exhaustive per-action-type default-params table (UI-SPEC Action Area
* Spec). Unknown/future action types fall through to `{}` rather than
* throwing, so newly introduced action types never break the Action Area.
*/
export function deriveDefaultParams(actionType: string, evidence: DefaultParamEvidence): Record<string, unknown> {
switch (actionType) {
case 'no_action':
return {};
case 'warn_user':
return { recipientEmail: evidence.requesterEmail ?? '', message: '' };
case 'block_sender':
return { senderEmail: evidence.senderEmail ?? '', senderDomain: evidence.senderDomain ?? '' };
case 'purge_message':
return { messageId: evidence.messageId ?? '', mailboxes: [] };
case 'reset_password':
return { userPrincipalName: evidence.requesterEmail ?? '' };
case 'isolate_endpoint':
return { deviceId: '' };
case 'disable_forwarding_rule':
return { userPrincipalName: evidence.requesterEmail ?? '', ruleName: '' };
default:
return {};
}
}