From fd92263416da3b558b77ec9feb6778d5865d3e03 Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 16 Jul 2026 18:59:33 -0400 Subject: [PATCH] docs(23): correct acknowledge_user delivery field per live Autotask verification Verified against TicketNotes/entityInformation/fields on the real tenant: publish has no client-facing value (1=All Autotask Users, 2=Internal Project Team, 4=Internal & Co-Managed -- all internal-staff tiers). Client Portal visibility is controlled by noteType=18 ("Client Portal Note"), not publish. Corrects D-03's original "flip the publish flag" framing before planning. --- .../23-CONTEXT.md | 19 +- .../23-PATTERNS.md | 489 ++++++++++++++++++ 2 files changed, 503 insertions(+), 5 deletions(-) create mode 100644 .planning/phases/23-classification-disposition-per-client-automation-gate/23-PATTERNS.md diff --git a/.planning/phases/23-classification-disposition-per-client-automation-gate/23-CONTEXT.md b/.planning/phases/23-classification-disposition-per-client-automation-gate/23-CONTEXT.md index 52a81ac..7dd4124 100644 --- a/.planning/phases/23-classification-disposition-per-client-automation-gate/23-CONTEXT.md +++ b/.planning/phases/23-classification-disposition-per-client-automation-gate/23-CONTEXT.md @@ -49,12 +49,21 @@ webhook fix (already shipped as quick task 260716-n46 in this same session). reporting it and reinforcing that their vigilance helps keep the company secure. Exact copy is Claude's discretion; tone should be genuinely appreciative, not templated-sounding. -- **D-03:** `acknowledge_user` delivers as a **customer-visible (Publish=true) - Autotask ticket note** — not an internal-only note like Phase 21's existing +- **D-03:** `acknowledge_user` delivers as a **customer-visible Autotask + ticket note** — not an internal-only note like Phase 21's existing triage-note, and not a separate email. Reuses the existing `TicketNotes` - write path from `triage-note-service.ts`, with the visibility flag flipped - for this specific action type so the reporting employee actually sees it - (via their normal ticket notification/portal). + write path from `triage-note-service.ts`. + **Verified against Autotask's live `TicketNotes/entityInformation/fields` + API (not assumed from code/migration comments, which conflicted):** + `publish` is NOT a client-visibility field — its full picklist is + `1=All Autotask Users`, `2=Internal Project Team`, `4=Internal & Co-Managed`, + all internal-staff-only tiers. The actual client-visibility field is + `noteType`, which has a dedicated value `18="Client Portal Note"`. Phase + 21's existing triage-note uses `noteType: 1` ("Task Summary") — an + unrelated internal categorization. **Correct implementation:** set + `noteType: 18` for `acknowledge_user` (instead of copying Phase 21's + `noteType: 1`); leave `publish: 1` unchanged (still required by the API, + orthogonal to client visibility). - **D-04:** `acknowledge_user` is exempt from Phase 20's proposed-only / manual-approval model — for companies with the automation gate's "report-to-ticket" stage enabled, it posts automatically without an diff --git a/.planning/phases/23-classification-disposition-per-client-automation-gate/23-PATTERNS.md b/.planning/phases/23-classification-disposition-per-client-automation-gate/23-PATTERNS.md new file mode 100644 index 0000000..1151faf --- /dev/null +++ b/.planning/phases/23-classification-disposition-per-client-automation-gate/23-PATTERNS.md @@ -0,0 +1,489 @@ +# Phase 23: Classification Disposition + Per-Client Automation Gate - Pattern Map + +**Mapped:** 2026-07-16 +**Files analyzed:** 9 +**Analogs found:** 9 / 9 + +## File Classification + +| New/Modified File | Role | Data Flow | Closest Analog | Match Quality | +|---|---|---|---|---| +| `lib/services/campaign-classifier.ts` (modify: `Verdict` union, `mapVerdictToActions`) | service (pure rule engine) | transform | itself (existing D-06/D-08 sections) | exact | +| `migrations/100_phishing_automation_gate.sql` (new) | migration | CRUD (settings table) | `migrations/082_company_scope.sql` (+ `081_integration_settings.sql` for audit cols) | exact | +| `app/admin/phishing-automation/page.tsx` (new) | component (admin page) | request-response | `app/admin/client-scope/page.tsx` | exact | +| `app/api/admin/phishing-automation/route.ts` (new, GET list) | route | CRUD | `app/api/admin/company-scope/route.ts` | exact | +| `app/api/admin/phishing-automation/[companyId]/route.ts` (new, PATCH) | route | CRUD | `app/api/admin/company-scope/[companyId]/route.ts` | exact | +| `lib/services/webhook-service.ts` (modify: `triggerPhishingDetection` + new sibling stage-runner) | service (event-driven trigger) | event-driven | itself (`triggerPhishingDetection`, lines 458-495) | exact | +| `lib/services/triage-note-service.ts` (modify: new `acknowledge_user` note writer, or param on existing writer) | service (Autotask write) | request-response (external API write) | itself (`generateAndPostTriageNote`, lines 85-195) | exact | +| `components/phishing/classification-card.tsx` (modify: new verdict badge + action label) | component | request-response | itself (`VERDICT_VARIANT_CLASS`/`ACTION_LABEL`, lines 30-44) | exact | +| `components/phishing/action-area-card.tsx` (modify: `acknowledge_user` checkbox + params form case) | component | request-response | itself (`ACTION_LABEL` + `ActionParamsForm` switch, lines 67-246) | exact | +| `lib/services/remediation-default-params.ts` (modify: 8th case in `deriveDefaultParams`) | utility (pure transform) | transform | itself (existing switch, lines 23-42) | exact | + +Every file's best analog is itself (same-file addition) or a near-identical sibling file already in the repo — this phase composes existing patterns, it introduces none. + +## Pattern Assignments + +### `lib/services/campaign-classifier.ts` (service, transform) + +**Analog:** itself — extend the existing `Verdict` union and switch statements in place. + +**Current verdict type + action mapping** (lines 150-186): +```typescript +export type Verdict = 'SPAM' | 'UNWANTED' | 'THREAT'; + +export function mapVerdictToActions(verdict: Verdict, evidence: ActionEvidence): string[] { + switch (verdict) { + case 'SPAM': + return ['no_action']; + case 'UNWANTED': + return ['warn_user']; + case 'THREAT': { + const actions = ['block_sender', 'purge_message']; + if (evidence.clicked > 0) { + actions.push('reset_password', 'isolate_endpoint', 'disable_forwarding_rule'); + } + return actions; + } + } +} +``` + +**Where the new verdict slots in** (D-06 allowlist short-circuit, lines 453-469 of `classifyCampaign`): +```typescript +const isSimulation = evidence.messages.some((message) => isKnownSimulationSender(message)); + +let verdict: Verdict; +const reasons: string[] = []; +if (isSimulation) { + verdict = evaluateSpamVsUnwanted(evidence); // <-- this line becomes: verdict = 'USER_AWARENESS'; + reasons.push( + 'Sender domain matches a known phishing-simulation vendor allowlist (KnowBe4/Breach Secure Now) — THREAT tier skipped' + ); +} else if (evaluateThreatTier(evidence)) { + verdict = 'THREAT'; +} else { + verdict = evaluateSpamVsUnwanted(evidence); +} +``` +**Concrete change:** add `'USER_AWARENESS'` (or discretion-chosen name) to the `Verdict` union; replace the `isSimulation` branch's call to `evaluateSpamVsUnwanted(evidence)` with a direct assignment to the new verdict (D-01/D-02 — this is the exact code path the classifier already isolates for the allowlist match, no new branching needed); add a `case 'USER_AWARENESS': return ['acknowledge_user'];` arm to `mapVerdictToActions`. `DESTRUCTIVE_ACTIONS` (line 153) and `computeRequiresApproval` are untouched — `acknowledge_user` must NOT be added to that set (D-04 exemption is enforced elsewhere, not via this invariant). + +**Constant reused unchanged:** `KNOWN_SIMULATION_SENDERS` / `isKnownSimulationSender` (lines 33-81) — detection logic is explicitly out of scope (CONTEXT.md "Does NOT cover"). + +--- + +### `migrations/100_phishing_automation_gate.sql` (migration, CRUD settings table) + +**Analog:** `migrations/082_company_scope.sql` (schema shape) + `migrations/081_integration_settings.sql` (audit columns, if added per Claude's Discretion). + +**company_scope full pattern to adapt** (082, lines 13-25): +```sql +CREATE TABLE IF NOT EXISTS company_scope ( + company_id BIGINT PRIMARY KEY REFERENCES companies(id) ON DELETE CASCADE, + in_scope BOOLEAN NOT NULL DEFAULT true, + updated_by TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_company_scope_in_scope + ON company_scope(in_scope) + WHERE in_scope = false; + +COMMENT ON TABLE company_scope IS + 'Opt-out scope filter for analytics. Absent row = in scope. in_scope=false = excluded from dashboard KPIs and ticket views.'; +``` + +**Key difference to apply (D-06 opt-IN, not opt-out):** three booleans, all `NOT NULL DEFAULT false` (opposite default polarity from `company_scope`'s `true`): +```sql +CREATE TABLE IF NOT EXISTS phishing_automation_gate ( + company_id BIGINT PRIMARY KEY REFERENCES companies(id) ON DELETE CASCADE, + auto_parse BOOLEAN NOT NULL DEFAULT false, + auto_classify BOOLEAN NOT NULL DEFAULT false, + auto_report BOOLEAN NOT NULL DEFAULT false, + updated_by TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` +Absent row = all three OFF (D-06) — mirror `company_scope`'s `COALESCE(cs.in_scope, true)` read pattern but with `false` as the fallback (see API route excerpt below). + +**Audit-column precedent, if adopted** (081, lines 14-22): +```sql +CREATE TABLE IF NOT EXISTS integration_settings ( + key TEXT PRIMARY KEY, + disabled BOOLEAN NOT NULL DEFAULT false, + disabled_reason TEXT, + disabled_by TEXT, + disabled_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` +If per-stage audit trail is wanted beyond a single `updated_by`/`updated_at`, that pattern is the closest existing precedent — but `company_scope`'s single-actor/single-timestamp columns are the more direct structural match since this table (like `company_scope`) is keyed by `company_id`, not by a settings `key`. + +**Next migration number:** latest existing is `099_indicators_metadata.sql` — use `100_*.sql`. + +--- + +### `app/admin/phishing-automation/page.tsx` (component, request-response) + +**Analog:** `app/admin/client-scope/page.tsx` (full file, 243 lines) — near line-for-line template. + +**Imports pattern** (lines 1-28): identical shadcn/PageHeader/lucide import block, reusable as-is (swap `Building2`/`EyeOff` icons for something automation-relevant, e.g. `Zap`/`Bot`). + +**State + data shape** (lines 30-49): `Company` interface gets three booleans instead of one: +```typescript +interface Company { + id: string; + companyName: string; + companyType: number | null; + companyTypeLabel: string | null; + autoParse: boolean; + autoClassify: boolean; + autoReport: boolean; +} +``` + +**Load pattern** (lines 51-76) — reuse verbatim, just repoint the fetch URL to `/api/admin/phishing-automation`. + +**Toggle pattern — the one structural difference** (lines 78-100): `client-scope` has a single `toggle(company, next)`. This page needs a per-stage toggle: +```typescript +async function toggle(company: Company, stage: 'autoParse' | 'autoClassify' | 'autoReport', next: boolean) { + setToggling(`${company.id}:${stage}`); + try { + const res = await fetch(`/api/admin/phishing-automation/${company.id}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ [stage]: next }), + }); + // ...same error/toast handling as client-scope lines 86-98 + } finally { + setToggling(null); + } +} +``` + +**Table row pattern** (lines 200-236) — same ``/`` shape, but the single trailing `` with one `` becomes three ``s, one `` each (D-05 three independent toggles): +```tsx + + void toggle(company, 'autoParse', next)} aria-label={`Auto-parse for ${company.companyName}`} /> + + + void toggle(company, 'autoClassify', next)} aria-label={`Auto-classify for ${company.companyName}`} /> + + + void toggle(company, 'autoReport', next)} aria-label={`Auto-report for ${company.companyName}`} /> + +``` + +**Search/filter/summary chrome** (lines 102-178) — reuse the search Input + type Select verbatim; the `scopeFilter`/`excluded` summary concept can be dropped or repurposed as "companies with any stage enabled" count — Claude's discretion, not load-bearing. + +--- + +### `app/api/admin/phishing-automation/route.ts` (route, CRUD) + +**Analog:** `app/api/admin/company-scope/route.ts` (full file, 75 lines). + +**Auth + query-param parsing** (lines 33-52) — reuse verbatim (`requireAdmin()`, `search`/`type` params, `conditions` array building). + +**Core LEFT JOIN + COALESCE pattern — the load-bearing part to adapt** (lines 54-62): +```typescript +const result = await postgresClient.query( + `SELECT c.id::text, c.company_name, c.company_type, + COALESCE(cs.in_scope, true) AS in_scope + FROM companies c + LEFT JOIN company_scope cs ON cs.company_id = c.id + WHERE ${conditions.join(' AND ')} + ORDER BY c.company_name`, + params +); +``` +**Adapted for opt-in defaults (D-06 — fallback is `false`, not `true`):** +```sql +SELECT c.id::text, c.company_name, c.company_type, + COALESCE(pag.auto_parse, false) AS auto_parse, + COALESCE(pag.auto_classify, false) AS auto_classify, + COALESCE(pag.auto_report, false) AS auto_report + FROM companies c + LEFT JOIN phishing_automation_gate pag ON pag.company_id = c.id + WHERE ${conditions.join(' AND ')} + ORDER BY c.company_name +``` + +**Response shape mapping** (lines 64-75) — same `.map()` + `NextResponse.json({...})` idiom, camelCase transform of the three booleans. + +--- + +### `app/api/admin/phishing-automation/[companyId]/route.ts` (route, CRUD) + +**Analog:** `app/api/admin/company-scope/[companyId]/route.ts` (full file, 59 lines) — PATCH pattern. + +**Upsert pattern, load-bearing** (lines 14-43): +```typescript +export async function PATCH(request: NextRequest, { params }: { params: Promise<{ companyId: string }> }) { + const { session, error } = await requireAdmin(); + if (error) return error; + + const { companyId } = await params; + const id = parseInt(companyId, 10); + if (isNaN(id)) return NextResponse.json({ error: 'Invalid companyId' }, { status: 400 }); + + const body = await request.json().catch(() => null); + if (body == null || typeof body.inScope !== 'boolean') { + return NextResponse.json({ error: 'body.inScope (boolean) required' }, { status: 400 }); + } + + const userEmail = (session?.user as any)?.email ?? null; + + await postgresClient.query( + `INSERT INTO company_scope (company_id, in_scope, updated_by, updated_at) + VALUES ($1, $2, $3, NOW()) + ON CONFLICT (company_id) + DO UPDATE SET in_scope = EXCLUDED.in_scope, + updated_by = EXCLUDED.updated_by, + updated_at = NOW()`, + [id, body.inScope, userEmail] + ); + + return NextResponse.json({ ok: true, companyId: id, inScope: body.inScope }); +} +``` +**Adaptation note:** since this table has three independently-toggleable booleans (not one), a partial-body PATCH (`{ autoParse: true }` alone, leaving classify/report untouched) needs an `INSERT ... ON CONFLICT DO UPDATE` that only overwrites the column(s) present in the body — e.g. `COALESCE($2, auto_parse)`-style merge, or require the client to always send all three current values (simpler, matches `client-scope`'s single-field simplicity, and the admin page's per-Switch `toggle()` call already knows the other two current values from local state — sending all three each PATCH avoids partial-update SQL complexity entirely). Recommend the latter for consistency with the one-field analog. + +**DELETE (revert to all-OFF default)** (lines 45-58) — reuse verbatim, same shape, table name swapped. + +--- + +### `lib/services/webhook-service.ts` (service, event-driven) + +**Analog:** itself — `triggerPhishingDetection` (lines 458-495) is the exact shape the new automatic parse/classify/report calls should follow. + +**Full existing method to extend/sibling from:** +```typescript +private async triggerPhishingDetection(payload: AutotaskWebhookPayload): Promise { + const row = await postgresClient.query<{ + id: string; ticket_number: string | null; title: string | null; description: string | null; + company_id: number | null; contact_id: number | null; created_by_contact_id: number | null; + }>( + `SELECT id, ticket_number, title, description, company_id, contact_id, created_by_contact_id + FROM tickets WHERE id = $1`, + [payload.entityId] + ); + + const r = row.rows[0]; + if (!r) { + console.warn(`[WEBHOOK] Skipping phishing detection — ticket ${payload.entityId} not found in Postgres yet`); + return; + } + + const ticket: DetectableTicket = { /* ...mapped fields... */ }; + + console.log(`[WEBHOOK] Triggering phishing detection for ticket ${payload.entityId}`); + const detection = await detectPhishingTicket(ticket); + if (detection.flagged && detection.reportId) { + await groupReportIntoCampaign(detection.reportId, { skipIfAlreadyGrouped: true }); + } +} +``` + +**Call site — fire-and-forget from `processWebhook`** (lines 114-122): +```typescript +if (payload.entityType === WebhookEntityType.TICKETS && payload.eventType === WebhookEventType.CREATE) { + this.triggerWorkflowEngine(payload).catch(err => console.error('[WEBHOOK] Workflow engine error:', err)); + this.triggerPhishingDetection(payload).catch(err => console.error('[WEBHOOK] Phishing detection error:', err)); +} +``` +**Integration point for this phase (per CONTEXT.md code_context):** after `groupReportIntoCampaign` succeeds inside `triggerPhishingDetection`, look up the resulting campaign's `company_id`, query `phishing_automation_gate` (`COALESCE(..., false)` per stage), and — following this SAME method's `if (detection.flagged && ...)` gating idiom — conditionally call, in order: `parseAndStoreMessage({ reportId, ticketId })` (if `auto_parse`) → `classifyCampaign(campaignId)` (if `auto_classify`, same underlying function `app/api/phishing/campaigns/[id]/classify/route.ts` calls — do not duplicate its logic) → `generateAndPostTriageNote(campaignId)`-equivalent stage-runner for `acknowledge_user`/report-to-ticket (if `auto_report`, and ONLY when the latest classification's verdict is `USER_AWARENESS` — D-04 says the auto-report carve-out is narrowly for `acknowledge_user`, not a general "post any note automatically" gate). Every stage call should be wrapped the same way — never block `processWebhook`'s response, log-and-continue on error (matches the fire-and-forget `.catch(err => console.error(...))` idiom at lines 119-121, not a per-stage try/catch that swallows and returns void like `triggerPhishingDetection`'s own body which lets errors propagate to the caller's `.catch`). + +--- + +### `lib/services/triage-note-service.ts` (service, external API write) + +**Analog:** itself — `generateAndPostTriageNote` (lines 85-195), specifically the `TicketNotes` write call (lines 176-183). + +**Existing write call:** +```typescript +await client.createEntity('TicketNotes', { + ticketID: Number(report.ticket_id), + title: 'Phishing Triage Summary', + description: noteText, + noteType: 1, // Internal + publish: 1, +}); +``` + +**RESOLVED (verified live against this tenant's `TicketNotes/entityInformation/fields` API, 2026-07-16) — `publish` is NOT the client-visibility field.** Its real picklist: `1="All Autotask Users"`, `2="Internal Project Team"`, `4="Internal & Co-Managed"` — all three are internal-staff tiers; there is no client-facing value in `publish` at all. The actual client-visibility field is `noteType`, which has a dedicated value `18="Client Portal Note"`. The existing triage-note's `noteType: 1` is "Task Summary" — an unrelated internal categorization, not a visibility control. **Correct implementation for `acknowledge_user`:** set `noteType: 18`, leave `publish: 1` unchanged (still required by the API, orthogonal to client visibility — do not touch it). Do not swap `publish` values; that field never controls client visibility for this entity. + +**Per-ticket loop + error isolation pattern to reuse (D-05 unchanged)** (lines 172-192): +```typescript +for (const report of reports) { + try { + await client.createEntity('TicketNotes', { /* ... */ }); + tickets.push({ ticketId: report.ticket_id, posted: true }); + } catch (err) { + console.error('[PHISHING-TRIAGE-NOTE] Failed to post note to ticket', report.ticket_id, err); + tickets.push({ ticketId: report.ticket_id, posted: false, error: err instanceof Error ? err.message : 'Unknown error' }); + } +} +``` +**Suggested shape for the new function:** a sibling `generateAndPostAcknowledgment(campaignId)` (mirrors `generateAndPostTriageNote`'s signature/return shape `{ noteText, tickets }`) with its own short thank-you copy (D-02, not the full evidence-dump `formatTriageNote()` template) and the flipped visibility value, OR a `visibility` param added to a shared internal helper both functions call — either is Claude's discretion per CONTEXT.md; the per-ticket try/catch-in-loop and `TriageNotePostResult[]` return shape must be preserved either way. + +--- + +### `components/phishing/classification-card.tsx` (component, request-response) + +**Analog:** itself — `VERDICT_VARIANT_CLASS` + `ACTION_LABEL` maps (lines 30-44) and the `ClassificationCardData['verdict']` type (line 15). + +**Current maps to extend:** +```typescript +export interface ClassificationCardData { + id: string; + verdict: 'SPAM' | 'UNWANTED' | 'THREAT'; // add the new verdict literal here + // ... +} + +const VERDICT_VARIANT_CLASS: Record = { + SPAM: 'bg-slate-500/15 text-slate-600', + UNWANTED: 'bg-amber-500/15 text-amber-600', + THREAT: 'bg-destructive/15 text-destructive', + // USER_AWARENESS: 'bg-emerald-500/15 text-emerald-600', <- distinct color per CONTEXT.md code_context +}; + +const ACTION_LABEL: Record = { + block_sender: 'Block sender', + purge_message: 'Purge message', + warn_user: 'Warn user', + no_action: 'No action', + reset_password: 'Reset password', + isolate_endpoint: 'Isolate endpoint', + disable_forwarding_rule: 'Disable forwarding rule', + // acknowledge_user: 'Acknowledge user', +}; +``` +`StatusBadge` consumption (line 115) needs no structural change — `variantClass` is already a free-form Tailwind class string (`CustomVariantProps` in `components/ui/status-badge.tsx`), so a new verdict key just needs a new entry in the Record, same pattern. + +--- + +### `components/phishing/action-area-card.tsx` (component, request-response) + +**Analog:** itself — `ACTION_LABEL` (lines 67-75) + `ActionParamsForm`'s switch (lines 111-246). + +**New checkbox case to add to `ActionParamsForm`** (pattern from the existing `no_action` case, lines 126-132, since `acknowledge_user` likely also needs no operator-editable params for the manual-approval path): +```typescript +case 'acknowledge_user': + return ( +

+ No parameters — posts a customer-visible thank-you note to the reporting employee. +

+ ); +``` +**Also add to `ACTION_LABEL`:** `acknowledge_user: 'Acknowledge user',` (same Record as classification-card.tsx — consider extracting to a shared constant if both files need to stay in sync, though CONTEXT.md doesn't mandate that refactor). + +**Note on D-04's approval exemption:** this component's checkbox/Approve-button flow (lines 375-399, `handleApprove` → `POST /api/phishing/campaigns/[id]/approve`) is the MANUAL path — CONTEXT.md D-04 says `acknowledge_user` only skips manual approval when the automation gate's report-to-ticket stage is enabled for that company. For companies WITHOUT that stage enabled, `acknowledge_user` still needs to render here exactly like every other action type (checkbox + Approve button), unchanged. No gating logic belongs in this component for the exemption — the exemption only affects the automatic webhook path (`webhook-service.ts` above), not this manual-review UI. + +--- + +### `lib/services/remediation-default-params.ts` (utility, transform) + +**Analog:** itself — the exhaustive switch (lines 23-42). + +**Existing pattern (8th case to add):** +```typescript +export function deriveDefaultParams(actionType: string, evidence: DefaultParamEvidence): Record { + switch (actionType) { + case 'no_action': + return {}; + // ... + case 'disable_forwarding_rule': + return { userPrincipalName: evidence.requesterEmail ?? '', ruleName: '' }; + default: + return {}; + } +} +``` +**Add:** +```typescript +case 'acknowledge_user': + return {}; // no operator-editable params (D-02 copy is fixed/templated server-side, not user-edited per-instance) +``` +Matches `no_action`'s empty-params precedent exactly — both are informational/non-destructive actions with nothing for an operator to fill in. + +--- + +## Shared Patterns + +### Admin-role route guard +**Source:** `app/api/admin/company-scope/route.ts` lines 33-35, `.../[companyId]/route.ts` lines 18-19 +**Apply to:** both new `app/api/admin/phishing-automation/*` routes +```typescript +const { session, error } = await requireAdmin(); +if (error) return error; +``` + +### Opt-in/opt-out settings-table read (COALESCE over LEFT JOIN) +**Source:** `app/api/admin/company-scope/route.ts` lines 54-62 +**Apply to:** new `phishing-automation` GET route (fallback flips from `true` to `false` per D-06) and the webhook-service.ts gate check +```sql +SELECT c.id, COALESCE(t.flag_column, ) AS flag + FROM companies c LEFT JOIN t ON t.company_id = c.id +``` + +### Upsert-on-toggle with actor stamping +**Source:** `app/api/admin/company-scope/[companyId]/route.ts` lines 30-40 +**Apply to:** new PATCH route +```typescript +const userEmail = (session?.user as any)?.email ?? null; +await postgresClient.query( + `INSERT INTO
(company_id, , updated_by, updated_at) + VALUES ($1, ..., $N, NOW()) + ON CONFLICT (company_id) + DO UPDATE SET = EXCLUDED., updated_by = EXCLUDED.updated_by, updated_at = NOW()`, + [id, ...values, userEmail] +); +``` + +### `hasPermission()` client-side gating (if the new admin page needs button-level gating beyond page-level `admin` role) +**Source:** `components/phishing/action-area-card.tsx` lines 300-303, `components/phishing/classification-card.tsx` lines 61-64; also referenced in Phase 22 CONTEXT.md D-06 +```typescript +const { data: session } = useSession(); +const role = (session?.user as { role?: string } | undefined)?.role ?? 'user'; +const canX = hasPermission(role, 'phishing', 'analyze'); +``` +Not strictly needed here since `/admin/phishing-automation` is already `admin`-role-gated at the page/route level (D-08), but available if any finer-grained control surfaces. + +### Fire-and-forget webhook stage trigger, error isolation +**Source:** `lib/services/webhook-service.ts` lines 119-121 +```typescript +this.triggerPhishingDetection(payload).catch(err => + console.error('[WEBHOOK] Phishing detection error:', err) +); +``` +**Apply to:** whatever new sibling method chains parse→classify→report; never `await` it inline in `processWebhook`'s main flow, always `.catch()` to a `console.error`. + +### Audit-event write on every classify/approve/remediate/mark-false-positive state change +**Source:** `lib/services/phishing-audit.ts` (`writeAuditEvent`), consumed at `app/api/phishing/campaigns/[id]/classify/route.ts` lines 50-56 +```typescript +const actor = (session?.user as { email?: string } | undefined)?.email ?? null; +await writeAuditEvent({ + campaignId: id, + actor, + eventType: 'campaign_classified', + payload: { verdict: result.verdict, requiresApproval: result.requiresApproval }, +}); +``` +**Apply to:** if the new automatic `acknowledge_user` post is treated as a state-changing event worth auditing (reasonable given every other phishing state change gets one), add a matching `eventType: 'acknowledge_user_posted'` (or similar) call — either in the new webhook stage-runner or inside the new triage-note-service function itself. Not explicitly decided in CONTEXT.md ("Claude's Discretion" — audit logging of automation-gate *toggle* changes is called out; audit logging of the *acknowledge_user post itself* is not mentioned but is a natural extension of the established pattern). + +## No Analog Found + +None — every file in this phase has a same-file or near-identical sibling analog already in the codebase. + +## Watch-out flags for the planner (not full patterns, but load-bearing) + +1. **`publish`/`noteType` semantics — RESOLVED** (see triage-note-service.ts section above): use `noteType: 18` ("Client Portal Note") for `acknowledge_user`'s customer visibility; `publish` stays `1` unchanged. Do not reintroduce the "flip publish" framing from the original discussion — verified live against Autotask's field metadata that `publish` has no client-facing value. +2. **D-04's exemption scope is narrow** — only the AUTOMATIC path (webhook-triggered, report-to-ticket stage enabled) skips approval for `acknowledge_user`. The MANUAL review UI (`action-area-card.tsx`) must still show `acknowledge_user` as a normal checkbox+Approve action for every company, regardless of gate state — do not add any gate-check logic to that component. +3. **Automatic report-to-ticket stage should only fire for `USER_AWARENESS`/`acknowledge_user`**, not as a generic "auto-post any note" toggle — the `auto_report` boolean's automatic behavior is scoped by CONTEXT.md to this one action type; other verdicts' actions still always require manual approval even when `auto_report` is on for that company (D-04 second sentence). +4. **`DESTRUCTIVE_ACTIONS` set in `campaign-classifier.ts` (line 153) must NOT include `acknowledge_user`** — it already won't by default (nothing adds it), just don't accidentally add it while wiring the new verdict/action. + +## Metadata + +**Analog search scope:** `lib/services/`, `app/api/admin/`, `app/api/phishing/`, `app/admin/`, `components/phishing/`, `migrations/` +**Files scanned:** ~20 (9 target files + 11 analog/reference files) +**Pattern extraction date:** 2026-07-16