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.
This commit is contained in:
parent
bd2fc240ec
commit
fd92263416
2 changed files with 503 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 `<Table>`/`<TableRow>` shape, but the single trailing `<TableCell>` with one `<Switch>` becomes three `<TableCell>`s, one `<Switch>` each (D-05 three independent toggles):
|
||||
```tsx
|
||||
<TableCell className="text-right">
|
||||
<Switch checked={company.autoParse} onCheckedChange={(next) => void toggle(company, 'autoParse', next)} aria-label={`Auto-parse for ${company.companyName}`} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Switch checked={company.autoClassify} onCheckedChange={(next) => void toggle(company, 'autoClassify', next)} aria-label={`Auto-classify for ${company.companyName}`} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Switch checked={company.autoReport} onCheckedChange={(next) => void toggle(company, 'autoReport', next)} aria-label={`Auto-report for ${company.companyName}`} />
|
||||
</TableCell>
|
||||
```
|
||||
|
||||
**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<CompanyRow>(
|
||||
`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<void> {
|
||||
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<ClassificationCardData['verdict'], string> = {
|
||||
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<string, string> = {
|
||||
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 (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No parameters — posts a customer-visible thank-you note to the reporting employee.
|
||||
</p>
|
||||
);
|
||||
```
|
||||
**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<string, unknown> {
|
||||
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, <default>) AS flag
|
||||
FROM companies c LEFT JOIN <settings_table> 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 <table> (company_id, <cols...>, updated_by, updated_at)
|
||||
VALUES ($1, ..., $N, NOW())
|
||||
ON CONFLICT (company_id)
|
||||
DO UPDATE SET <cols...> = EXCLUDED.<cols...>, 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue