From 50e241592c394fd78f06650e9a2098322c1f08ec Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 16 Jul 2026 19:37:56 -0400 Subject: [PATCH] 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 --- lib/services/triage-note-service.test.ts | 68 +++++++++++++++++++++++- lib/services/triage-note-service.ts | 56 +++++++++++++++++++ 2 files changed, 123 insertions(+), 1 deletion(-) diff --git a/lib/services/triage-note-service.test.ts b/lib/services/triage-note-service.test.ts index 4d7e5da..ee809c3 100644 --- a/lib/services/triage-note-service.test.ts +++ b/lib/services/triage-note-service.test.ts @@ -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(); + }); +}); diff --git a/lib/services/triage-note-service.ts b/lib/services/triage-note-service.ts index 06bb155..c8f90fd 100644 --- a/lib/services/triage-note-service.ts +++ b/lib/services/triage-note-service.ts @@ -193,3 +193,59 @@ export async function generateAndPostTriageNote(campaignId: string): Promise { + const reportsRes = await postgresClient.query>( + `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 }; +}