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:
parent
14ed8ca248
commit
50e241592c
2 changed files with 123 additions and 1 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue