feat(23-01): add generateAndPostAcknowledgment customer-visible note writer

- New generateAndPostAcknowledgment(campaignId) posts a short, appreciative
  thank-you note to every ticket linked to a campaign, using noteType 18
  (Client Portal Note, verified live against tenant's TicketNotes field
  metadata) so the note is customer-visible; publish stays 1 unchanged
- Body is a fixed template with zero evidence/URL/classification
  interpolation (T-23-01) -- not the evidence-dump formatTriageNote() template
- Mirrors generateAndPostTriageNote's per-ticket try/catch-in-loop error
  isolation and { noteText, tickets } return shape
- Tests: noteType 18 + publish 1 payload assertion, per-ticket failure
  isolation, and zero-linked-reports case
This commit is contained in:
lorentz 2026-07-16 19:37:56 -04:00
parent 14ed8ca248
commit 50e241592c
2 changed files with 123 additions and 1 deletions

View file

@ -36,7 +36,7 @@ vi.mock('./triage-note-format', async (importOriginal) => {
});
// eslint-disable-next-line import/first -- imported after vi.mock hoisting
import { generateAndPostTriageNote } from './triage-note-service';
import { generateAndPostTriageNote, generateAndPostAcknowledgment } from './triage-note-service';
interface MockRows {
reports?: unknown[];
@ -257,3 +257,69 @@ describe('generateAndPostTriageNote', () => {
expect(result.noteText).toContain('not yet classified');
});
});
describe('generateAndPostAcknowledgment', () => {
it('posts a customer-visible (noteType 18, publish 1) thank-you note to every linked ticket', async () => {
stage({
reports: [
report({ id: 'r1', ticket_id: '1001' }),
report({ id: 'r2', ticket_id: '1002' }),
],
});
const result = await generateAndPostAcknowledgment('campaign-1');
expect(createEntityMock).toHaveBeenCalledTimes(2);
for (const [entityName, data] of createEntityMock.mock.calls) {
expect(entityName).toBe('TicketNotes');
expect(data).toMatchObject({
description: result.noteText,
noteType: 18,
publish: 1,
});
expect(typeof (data as { ticketID: unknown }).ticketID).toBe('number');
}
expect(createEntityMock.mock.calls.map((c) => (c[1] as { ticketID: number }).ticketID)).toEqual([1001, 1002]);
expect(result.tickets).toEqual([
{ ticketId: '1001', posted: true },
{ ticketId: '1002', posted: true },
]);
expect(result.noteText.length).toBeGreaterThan(0);
// Appreciative, non-evidence-dump body — no evidence/URL/secret interpolation.
expect(result.noteText).not.toContain('Blast Radius');
expect(result.noteText).not.toContain('Recommended Actions');
});
it('isolates a single ticket write failure without aborting the remaining writes', async () => {
stage({
reports: [
report({ id: 'r1', ticket_id: '1001' }),
report({ id: 'r2', ticket_id: '1002' }),
report({ id: 'r3', ticket_id: '1003' }),
],
});
createEntityMock
.mockResolvedValueOnce({ id: 1 })
.mockRejectedValueOnce(new Error('Autotask API unavailable'))
.mockResolvedValueOnce({ id: 3 });
const result = await generateAndPostAcknowledgment('campaign-1');
expect(createEntityMock).toHaveBeenCalledTimes(3);
expect(result.tickets[0]).toEqual({ ticketId: '1001', posted: true });
expect(result.tickets[1]).toMatchObject({ ticketId: '1002', posted: false });
expect(result.tickets[1].error).toBe('Autotask API unavailable');
expect(result.tickets[2]).toEqual({ ticketId: '1003', posted: true });
});
it('resolves { noteText, tickets: [] } for a campaign with zero linked reports, without throwing', async () => {
stage({ reports: [] });
const result = await generateAndPostAcknowledgment('campaign-empty');
expect(result.tickets).toEqual([]);
expect(typeof result.noteText).toBe('string');
expect(result.noteText.length).toBeGreaterThan(0);
expect(createEntityMock).not.toHaveBeenCalled();
});
});

View file

@ -193,3 +193,59 @@ export async function generateAndPostTriageNote(campaignId: string): Promise<Tri
return { noteText, tickets };
}
/**
* Phase 23 D-02/D-03: posts a short, genuinely appreciative thank-you note
* to every ticket linked to a USER_AWARENESS campaign the `acknowledge_user`
* delivery action. Unlike `generateAndPostTriageNote` (an internal evidence
* dump), this note is customer-visible: `noteType: 18` ("Client Portal
* Note", verified live against this tenant's TicketNotes field metadata)
* with `publish: 1` left unchanged (an internal-staff-tier field, orthogonal
* to client visibility see triage-note-format.ts / 23-PATTERNS.md watch-out
* flag #1). The body is a FIXED template with zero evidence/URL/classification
* interpolation (T-23-01) nothing from the parsed email or classification
* reasons is ever placed in this note.
*
* Mirrors `generateAndPostTriageNote`'s per-ticket try/catch-in-loop error
* isolation (D-05) and `{ noteText, tickets }` return shape.
*/
export async function generateAndPostAcknowledgment(campaignId: string): Promise<TriageNoteResult> {
const reportsRes = await postgresClient.query<Pick<ReportRow, 'id' | 'ticket_id'>>(
`SELECT id::text, ticket_id::text AS ticket_id
FROM reports WHERE campaign_id = $1 ORDER BY created_at ASC`,
[campaignId]
);
const reports = reportsRes.rows;
const noteText = [
'Thank you for reporting this email as suspicious!',
'',
'Your quick action in flagging this message is exactly the kind of vigilance that helps keep our organization secure. We really appreciate you taking the time to report it — please keep it up.',
].join('\n');
const client = getAutotaskClient();
const tickets: TriageNotePostResult[] = [];
for (const report of reports) {
// Per-ticket try/catch is INSIDE the loop (not around it) so one
// ticket's write failure never aborts the remaining writes (D-05).
try {
await client.createEntity('TicketNotes', {
ticketID: Number(report.ticket_id),
title: 'Thank You — Suspicious Email Reported',
description: noteText,
noteType: 18, // Client Portal Note — customer-visible (D-03)
publish: 1,
});
tickets.push({ ticketId: report.ticket_id, posted: true });
} catch (err) {
console.error('[PHISHING-ACKNOWLEDGMENT] 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',
});
}
}
return { noteText, tickets };
}