mirror of
https://github.com/FoggedLens/deflock.git
synced 2026-08-18 00:17:13 +02:00
commit for the purpose of working on another machine
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { AiScreeningClient } from './AiScreeningClient';
|
||||
|
||||
function makeStubOpenAi(create: (...args: any[]) => Promise<any>) {
|
||||
return { chat: { completions: { create } } } as any;
|
||||
}
|
||||
|
||||
const validResult = {
|
||||
ai_category: 'media_press',
|
||||
media_tier: 'big_media',
|
||||
confidence: 0.9,
|
||||
kb_reference: 'none',
|
||||
suggested_action: 'escalate_urgent',
|
||||
suggested_reply: '',
|
||||
internal_note: 'Reporter from a national outlet asking for comment, no stated deadline.',
|
||||
risk_flags: [],
|
||||
};
|
||||
|
||||
function completionWith(content: string) {
|
||||
return { choices: [{ message: { content } }] };
|
||||
}
|
||||
|
||||
describe('AiScreeningClient.screen', () => {
|
||||
it('sends the system prompt, structured response_format, and a user message with only topic/subject/message/senderEmailDomain', async () => {
|
||||
let capturedArgs: any;
|
||||
let capturedOptions: any;
|
||||
const create = async (args: any, options: any) => {
|
||||
capturedArgs = args;
|
||||
capturedOptions = options;
|
||||
return completionWith(JSON.stringify(validResult));
|
||||
};
|
||||
|
||||
const client = new AiScreeningClient(makeStubOpenAi(create));
|
||||
await client.screen({
|
||||
topic: 'media',
|
||||
subject: 'Feature pitch',
|
||||
message: 'We would like to interview your team.',
|
||||
senderEmailDomain: 'nytimes.com',
|
||||
});
|
||||
|
||||
expect(capturedArgs.messages[0].role).toBe('system');
|
||||
expect(capturedArgs.messages[0].content.length).toBeGreaterThan(0);
|
||||
expect(capturedArgs.response_format.type).toBe('json_schema');
|
||||
expect(capturedArgs.response_format.json_schema.strict).toBe(true);
|
||||
|
||||
const userContent = JSON.parse(capturedArgs.messages[1].content);
|
||||
expect(userContent).toEqual({
|
||||
topic: 'media',
|
||||
subject: 'Feature pitch',
|
||||
message: 'We would like to interview your team.',
|
||||
senderEmailDomain: 'nytimes.com',
|
||||
});
|
||||
expect(capturedOptions.timeout).toBe(20_000);
|
||||
});
|
||||
|
||||
it('constrains kb_reference to "none" plus whatever .md filenames are actually loaded from kb/', async () => {
|
||||
let capturedArgs: any;
|
||||
const create = async (args: any) => {
|
||||
capturedArgs = args;
|
||||
return completionWith(JSON.stringify(validResult));
|
||||
};
|
||||
|
||||
const client = new AiScreeningClient(makeStubOpenAi(create));
|
||||
await client.screen({ topic: 'media', subject: 'x', message: 'y', senderEmailDomain: '' });
|
||||
|
||||
const enumValues: string[] = capturedArgs.response_format.json_schema.schema.properties.kb_reference.enum;
|
||||
expect(enumValues).toContain('none');
|
||||
// Every non-"none" value must correspond to a real, loaded .md file — never an invented name.
|
||||
for (const value of enumValues) {
|
||||
if (value !== 'none') expect(value.endsWith('.md')).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('parses a valid structured response into a ScreeningResult', async () => {
|
||||
const create = async () => completionWith(JSON.stringify(validResult));
|
||||
const client = new AiScreeningClient(makeStubOpenAi(create));
|
||||
|
||||
const result = await client.screen({
|
||||
topic: 'media',
|
||||
subject: 'x',
|
||||
message: 'y',
|
||||
senderEmailDomain: 'nytimes.com',
|
||||
});
|
||||
|
||||
expect(result).toEqual(validResult);
|
||||
});
|
||||
|
||||
it('throws when the SDK call rejects', async () => {
|
||||
const create = async () => { throw new Error('OpenAI request failed: 500 boom'); };
|
||||
const client = new AiScreeningClient(makeStubOpenAi(create));
|
||||
|
||||
await expect(client.screen({ topic: 'media', subject: 'x', message: 'y', senderEmailDomain: '' }))
|
||||
.rejects.toThrow('OpenAI request failed: 500 boom');
|
||||
});
|
||||
|
||||
it('throws when the response has no content', async () => {
|
||||
const create = async () => ({ choices: [{ message: {} }] });
|
||||
const client = new AiScreeningClient(makeStubOpenAi(create));
|
||||
|
||||
await expect(client.screen({ topic: 'media', subject: 'x', message: 'y', senderEmailDomain: '' }))
|
||||
.rejects.toThrow('OpenAI screening response missing content');
|
||||
});
|
||||
|
||||
it('throws when the content is not valid JSON', async () => {
|
||||
const create = async () => completionWith('not json');
|
||||
const client = new AiScreeningClient(makeStubOpenAi(create));
|
||||
|
||||
await expect(client.screen({ topic: 'media', subject: 'x', message: 'y', senderEmailDomain: '' }))
|
||||
.rejects.toThrow('OpenAI screening response was not valid JSON');
|
||||
});
|
||||
|
||||
it('throws when the parsed JSON fails schema validation', async () => {
|
||||
const { ai_category, ...incomplete } = validResult;
|
||||
const create = async () => completionWith(JSON.stringify(incomplete));
|
||||
const client = new AiScreeningClient(makeStubOpenAi(create));
|
||||
|
||||
await expect(client.screen({ topic: 'media', subject: 'x', message: 'y', senderEmailDomain: '' }))
|
||||
.rejects.toThrow('OpenAI screening response failed schema validation');
|
||||
});
|
||||
|
||||
it('throws when a value is outside the enum (e.g. an unrecognized ai_category)', async () => {
|
||||
const create = async () => completionWith(JSON.stringify({ ...validResult, ai_category: 'made_up_category' }));
|
||||
const client = new AiScreeningClient(makeStubOpenAi(create));
|
||||
|
||||
await expect(client.screen({ topic: 'media', subject: 'x', message: 'y', senderEmailDomain: '' }))
|
||||
.rejects.toThrow('OpenAI screening response failed schema validation');
|
||||
});
|
||||
|
||||
it('throws when internal_note is an empty string, rather than silently allowing a blank note', async () => {
|
||||
const create = async () => completionWith(JSON.stringify({ ...validResult, internal_note: '' }));
|
||||
const client = new AiScreeningClient(makeStubOpenAi(create));
|
||||
|
||||
await expect(client.screen({ topic: 'media', subject: 'x', message: 'y', senderEmailDomain: '' }))
|
||||
.rejects.toThrow('OpenAI screening response failed schema validation');
|
||||
});
|
||||
|
||||
it('throws when suggested_action is draft_response but suggested_reply is empty, rather than silently creating a blank shared draft', async () => {
|
||||
const create = async () => completionWith(JSON.stringify({
|
||||
...validResult,
|
||||
ai_category: 'camera_report',
|
||||
suggested_action: 'draft_response',
|
||||
suggested_reply: '',
|
||||
}));
|
||||
const client = new AiScreeningClient(makeStubOpenAi(create));
|
||||
|
||||
await expect(client.screen({ topic: 'media', subject: 'x', message: 'y', senderEmailDomain: '' }))
|
||||
.rejects.toThrow('OpenAI screening response has an empty suggested_reply for a draft_response category');
|
||||
});
|
||||
|
||||
it('allows an empty suggested_reply for non-draft_response actions', async () => {
|
||||
const create = async () => completionWith(JSON.stringify(validResult)); // escalate_urgent, empty reply
|
||||
const client = new AiScreeningClient(makeStubOpenAi(create));
|
||||
|
||||
const result = await client.screen({ topic: 'media', subject: 'x', message: 'y', senderEmailDomain: '' });
|
||||
expect(result.suggested_reply).toBe('');
|
||||
});
|
||||
|
||||
it('passes when suggested_action is draft_response and suggested_reply is populated', async () => {
|
||||
const create = async () => completionWith(JSON.stringify({
|
||||
...validResult,
|
||||
ai_category: 'camera_report',
|
||||
suggested_action: 'draft_response',
|
||||
suggested_reply: 'Here is how to report it yourself...',
|
||||
}));
|
||||
const client = new AiScreeningClient(makeStubOpenAi(create));
|
||||
|
||||
const result = await client.screen({ topic: 'media', subject: 'x', message: 'y', senderEmailDomain: '' });
|
||||
expect(result.suggested_reply).toBe('Here is how to report it yourself...');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { Type, Static } from '@sinclair/typebox';
|
||||
import { Value } from '@sinclair/typebox/value';
|
||||
import OpenAI from 'openai';
|
||||
import type { ContactTopic } from './ZammadClient';
|
||||
import { loadKnowledgeBase, formatKnowledgeBaseForPrompt } from './KnowledgeBase';
|
||||
|
||||
const OPENAI_API_KEY = process.env.OPENAI_API_KEY || '';
|
||||
const OPENAI_MODEL = process.env.OPENAI_MODEL || 'gpt-4o-mini';
|
||||
|
||||
const PROMPT_TEMPLATE = readFileSync(join(__dirname, '../prompts/contact-screening.md'), 'utf-8');
|
||||
|
||||
const KB_DOCS = loadKnowledgeBase();
|
||||
const KB_FILENAMES = KB_DOCS.map(d => d.filename);
|
||||
const KB_REFERENCE_VALUES = [...KB_FILENAMES, 'none'] as const;
|
||||
|
||||
const SYSTEM_PROMPT = `${PROMPT_TEMPLATE}\n\n${formatKnowledgeBaseForPrompt(KB_DOCS)}`;
|
||||
|
||||
export const AI_CATEGORIES = [
|
||||
'local_group_request',
|
||||
'camera_report',
|
||||
'camera_correction',
|
||||
'technical_bug',
|
||||
'media_press',
|
||||
'legal',
|
||||
'donation',
|
||||
'api_data',
|
||||
'opinion_no_action',
|
||||
'spam_bounce',
|
||||
'other',
|
||||
] as const;
|
||||
|
||||
// Only meaningful when ai_category is media_press; not_applicable otherwise.
|
||||
export const MEDIA_TIER_VALUES = ['big_media', 'small_media', 'not_applicable'] as const;
|
||||
|
||||
export const RISK_FLAG_VALUES = [
|
||||
'prompt_injection_suspected',
|
||||
'abusive_or_threatening',
|
||||
'spam_or_irrelevant',
|
||||
] as const;
|
||||
|
||||
export const SUGGESTED_ACTION_VALUES = [
|
||||
'draft_response',
|
||||
'internal_note_only',
|
||||
'escalate_urgent',
|
||||
'auto_delete',
|
||||
'scheduled_close',
|
||||
] as const;
|
||||
|
||||
function literalUnion<T extends readonly string[]>(values: T) {
|
||||
return Type.Union(values.map(v => Type.Literal(v)) as any);
|
||||
}
|
||||
|
||||
export const ScreeningResultSchema = Type.Object({
|
||||
ai_category: literalUnion(AI_CATEGORIES),
|
||||
media_tier: literalUnion(MEDIA_TIER_VALUES),
|
||||
confidence: Type.Number(),
|
||||
kb_reference: literalUnion(KB_REFERENCE_VALUES),
|
||||
suggested_action: literalUnion(SUGGESTED_ACTION_VALUES),
|
||||
suggested_reply: Type.String(),
|
||||
// Required and non-empty: the schema only guarantees the key is present, so without
|
||||
// minLength the model can (and did) satisfy "required" with an empty string. This is
|
||||
// enforced locally by Value.Check below regardless of whether OpenAI's strict mode honors
|
||||
// minLength on its end — an empty note now fails loudly instead of silently writing nothing.
|
||||
internal_note: Type.String({ minLength: 1 }),
|
||||
risk_flags: Type.Array(literalUnion(RISK_FLAG_VALUES)),
|
||||
});
|
||||
export type ScreeningResult = Static<typeof ScreeningResultSchema>;
|
||||
|
||||
export interface ScreeningInput {
|
||||
topic: ContactTopic;
|
||||
subject: string;
|
||||
message: string;
|
||||
senderEmailDomain: string;
|
||||
}
|
||||
|
||||
// Mirrors ScreeningResultSchema. OpenAI Structured Outputs strict mode requires every
|
||||
// property to be listed in "required" and additionalProperties: false throughout.
|
||||
const OPENAI_JSON_SCHEMA = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
ai_category: { type: 'string', enum: AI_CATEGORIES },
|
||||
media_tier: { type: 'string', enum: MEDIA_TIER_VALUES },
|
||||
confidence: { type: 'number', minimum: 0, maximum: 1 },
|
||||
kb_reference: { type: 'string', enum: KB_REFERENCE_VALUES },
|
||||
suggested_action: { type: 'string', enum: SUGGESTED_ACTION_VALUES },
|
||||
suggested_reply: { type: 'string' },
|
||||
internal_note: { type: 'string', minLength: 1 },
|
||||
risk_flags: { type: 'array', items: { type: 'string', enum: RISK_FLAG_VALUES } },
|
||||
},
|
||||
required: [
|
||||
'ai_category',
|
||||
'media_tier',
|
||||
'confidence',
|
||||
'kb_reference',
|
||||
'suggested_action',
|
||||
'suggested_reply',
|
||||
'internal_note',
|
||||
'risk_flags',
|
||||
],
|
||||
};
|
||||
|
||||
export class AiScreeningClient {
|
||||
private readonly openai: OpenAI;
|
||||
|
||||
constructor(openai: OpenAI = new OpenAI({ apiKey: OPENAI_API_KEY })) {
|
||||
this.openai = openai;
|
||||
}
|
||||
|
||||
async screen(input: ScreeningInput): Promise<ScreeningResult> {
|
||||
const response = await this.openai.chat.completions.create(
|
||||
{
|
||||
model: OPENAI_MODEL,
|
||||
temperature: 0.2,
|
||||
messages: [
|
||||
{ role: 'system', content: SYSTEM_PROMPT },
|
||||
{
|
||||
role: 'user',
|
||||
content: JSON.stringify({
|
||||
topic: input.topic,
|
||||
subject: input.subject,
|
||||
message: input.message,
|
||||
senderEmailDomain: input.senderEmailDomain,
|
||||
}),
|
||||
},
|
||||
],
|
||||
response_format: {
|
||||
type: 'json_schema',
|
||||
json_schema: { name: 'contact_screening_result', strict: true, schema: OPENAI_JSON_SCHEMA },
|
||||
},
|
||||
},
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
|
||||
const content = response.choices?.[0]?.message?.content;
|
||||
if (!content) {
|
||||
throw new Error('OpenAI screening response missing content');
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(content);
|
||||
} catch {
|
||||
throw new Error('OpenAI screening response was not valid JSON');
|
||||
}
|
||||
|
||||
if (!Value.Check(ScreeningResultSchema, parsed)) {
|
||||
throw new Error('OpenAI screening response failed schema validation');
|
||||
}
|
||||
|
||||
// suggested_reply is legitimately empty for non-draft categories (media_press, legal,
|
||||
// opinion_no_action, spam_bounce), so it can't just be minLength'd in the schema like
|
||||
// internal_note was. But when suggested_action is draft_response, an empty reply means an
|
||||
// empty Zammad shared draft gets silently created with no error — enforce it here instead.
|
||||
if (parsed.suggested_action === 'draft_response' && !parsed.suggested_reply.trim()) {
|
||||
throw new Error('OpenAI screening response has an empty suggested_reply for a draft_response category');
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { endOfWorkdayEasternIso } from './BusinessHours';
|
||||
|
||||
describe('endOfWorkdayEasternIso', () => {
|
||||
it('resolves to 21:00 UTC (5pm EDT) during daylight saving time', () => {
|
||||
const now = new Date('2026-07-16T12:00:00Z'); // July -> EDT, UTC-4
|
||||
expect(endOfWorkdayEasternIso(now)).toBe('2026-07-16T21:00:00.000Z');
|
||||
});
|
||||
|
||||
it('resolves to 22:00 UTC (5pm EST) outside daylight saving time', () => {
|
||||
const now = new Date('2026-01-16T12:00:00Z'); // January -> EST, UTC-5
|
||||
expect(endOfWorkdayEasternIso(now)).toBe('2026-01-16T22:00:00.000Z');
|
||||
});
|
||||
|
||||
it('uses the New York calendar date, not the UTC calendar date', () => {
|
||||
// 1am UTC on the 17th is still 9pm on the 16th in New York (EDT, UTC-4)
|
||||
const now = new Date('2026-07-17T01:00:00Z');
|
||||
expect(endOfWorkdayEasternIso(now)).toBe('2026-07-16T21:00:00.000Z');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
const WORKDAY_TIMEZONE = 'America/New_York';
|
||||
const WORKDAY_END_HOUR = 17;
|
||||
|
||||
function getTzOffsetMinutes(timeZone: string, utcMs: number): number {
|
||||
const dtf = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
hourCycle: 'h23',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
const parts = dtf.formatToParts(new Date(utcMs));
|
||||
const map: Record<string, string> = {};
|
||||
for (const p of parts) {
|
||||
if (p.type !== 'literal') map[p.type] = p.value;
|
||||
}
|
||||
const asIfUtc = Date.UTC(+map.year, +map.month - 1, +map.day, +map.hour, +map.minute, +map.second);
|
||||
return (asIfUtc - utcMs) / 60_000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an ISO 8601 UTC timestamp for 5:00 PM America/New_York on the
|
||||
* calendar date `now` falls on in that timezone. Correctly accounts for
|
||||
* EST/EDT by computing the actual UTC offset for that specific date.
|
||||
*/
|
||||
export function endOfWorkdayEasternIso(now: Date = new Date()): string {
|
||||
const dateParts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: WORKDAY_TIMEZONE,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(now);
|
||||
const [year, month, day] = dateParts.split('-').map(Number);
|
||||
|
||||
const naiveUtcMs = Date.UTC(year, month - 1, day, WORKDAY_END_HOUR, 0, 0);
|
||||
const offsetMinutes = getTzOffsetMinutes(WORKDAY_TIMEZONE, naiveUtcMs);
|
||||
const targetUtcMs = naiveUtcMs - offsetMinutes * 60_000;
|
||||
return new Date(targetUtcMs).toISOString();
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { describe, it, expect, mock } from 'bun:test';
|
||||
import { planZammadActions, screenContactSubmission } from './ContactScreeningService';
|
||||
import type { ScreeningResult } from './AiScreeningClient';
|
||||
|
||||
const base: ScreeningResult = {
|
||||
ai_category: 'other',
|
||||
media_tier: 'not_applicable',
|
||||
confidence: 0.5,
|
||||
kb_reference: 'none',
|
||||
suggested_action: 'internal_note_only',
|
||||
suggested_reply: '',
|
||||
internal_note: 'Unclear request.',
|
||||
risk_flags: [],
|
||||
};
|
||||
|
||||
function makeDeps(screenResult: ScreeningResult) {
|
||||
const addTag = mock(async () => {});
|
||||
const addInternalNote = mock(async () => {});
|
||||
const setTicketFields = mock(async () => {});
|
||||
const upsertSharedDraft = mock(async () => {});
|
||||
const aiClient = { screen: mock(async () => screenResult) } as any;
|
||||
const zammadClient = { addTag, addInternalNote, setTicketFields, upsertSharedDraft } as any;
|
||||
return { aiClient, zammadClient, addTag, addInternalNote, setTicketFields, upsertSharedDraft };
|
||||
}
|
||||
|
||||
describe('planZammadActions', () => {
|
||||
it('media_press (big_media): escalates, tags media + media-big, bumps priority, routes to Media group, no shared draft, writes a note', () => {
|
||||
const plan = planZammadActions({
|
||||
...base,
|
||||
ai_category: 'media_press',
|
||||
media_tier: 'big_media',
|
||||
suggested_action: 'escalate_urgent',
|
||||
internal_note: 'AP reporter, deadline Friday.',
|
||||
});
|
||||
|
||||
expect(plan.tags).toEqual(expect.arrayContaining(['ai-screened', 'media', 'media-big']));
|
||||
expect(plan.tags).not.toContain('media-small');
|
||||
expect(plan.priorityUpdate).toBe('3 high');
|
||||
expect(plan.groupOverride).toBe('Media');
|
||||
expect(plan.sharedDraft).toBeNull();
|
||||
expect(plan.noteBody).toBe('AP reporter, deadline Friday.');
|
||||
expect(plan.state).toBeNull();
|
||||
});
|
||||
|
||||
it('media_press (small_media): tags media + media-small, still escalates and routes to Media group', () => {
|
||||
const plan = planZammadActions({ ...base, ai_category: 'media_press', media_tier: 'small_media', suggested_action: 'escalate_urgent' });
|
||||
|
||||
expect(plan.tags).toContain('media-small');
|
||||
expect(plan.tags).not.toContain('media-big');
|
||||
expect(plan.groupOverride).toBe('Media');
|
||||
expect(plan.priorityUpdate).toBe('3 high');
|
||||
});
|
||||
|
||||
it('legal: escalates, tags legal, bumps priority, does not route to Media group', () => {
|
||||
const plan = planZammadActions({ ...base, ai_category: 'legal', suggested_action: 'escalate_urgent' });
|
||||
expect(plan.tags).toContain('legal');
|
||||
expect(plan.priorityUpdate).toBe('3 high');
|
||||
expect(plan.groupOverride).toBeNull();
|
||||
});
|
||||
|
||||
it('camera_correction: tags camera-correction and is KB-grounded (kb-gap when uncited)', () => {
|
||||
const plan = planZammadActions({ ...base, ai_category: 'camera_correction', suggested_action: 'draft_response', kb_reference: 'none' });
|
||||
expect(plan.tags).toContain('camera-correction');
|
||||
expect(plan.tags).toContain('kb-gap');
|
||||
});
|
||||
|
||||
it('technical_bug: writes both a shared draft and an internal note', () => {
|
||||
const plan = planZammadActions({ ...base, ai_category: 'technical_bug', suggested_action: 'draft_response', internal_note: 'Map fails to load on Safari.' });
|
||||
expect(plan.sharedDraft).not.toBeNull();
|
||||
expect(plan.noteBody).toBe('Map fails to load on Safari.');
|
||||
expect(plan.tags).toContain('technical-bug');
|
||||
});
|
||||
|
||||
it('local_group_request: writes both a shared draft and an internal note summary', () => {
|
||||
const plan = planZammadActions({ ...base, ai_category: 'local_group_request', suggested_action: 'draft_response', internal_note: 'Wants to start a group in Ohio.' });
|
||||
expect(plan.sharedDraft).not.toBeNull();
|
||||
expect(plan.noteBody).toBe('Wants to start a group in Ohio.');
|
||||
});
|
||||
|
||||
it('opinion_no_action: schedules a pending close, no draft, priority set to low', () => {
|
||||
const plan = planZammadActions({ ...base, ai_category: 'opinion_no_action', suggested_action: 'scheduled_close', internal_note: 'Unsolicited opinion.' });
|
||||
expect(plan.sharedDraft).toBeNull();
|
||||
expect(plan.state).toBe('pending close');
|
||||
expect(plan.pendingTime).not.toBeNull();
|
||||
expect(plan.priorityUpdate).toBe('1 low');
|
||||
expect(plan.noteBody).toBe('Unsolicited opinion.');
|
||||
});
|
||||
|
||||
it('spam_bounce: schedules a pending close via auto_delete, priority set to low', () => {
|
||||
const plan = planZammadActions({ ...base, ai_category: 'spam_bounce', suggested_action: 'auto_delete', internal_note: 'Spam.' });
|
||||
expect(plan.state).toBe('pending close');
|
||||
expect(plan.pendingTime).not.toBeNull();
|
||||
expect(plan.sharedDraft).toBeNull();
|
||||
expect(plan.priorityUpdate).toBe('1 low');
|
||||
});
|
||||
|
||||
it('donation/api_data/other/camera_correction: tag kb-gap when kb_reference is none', () => {
|
||||
const plan = planZammadActions({ ...base, ai_category: 'donation', suggested_action: 'internal_note_only', kb_reference: 'none' });
|
||||
expect(plan.tags).toContain('kb-gap');
|
||||
});
|
||||
|
||||
it('camera_report and local_group_request are also KB-grounded and tag kb-gap when kb_reference is none', () => {
|
||||
const cameraReport = planZammadActions({ ...base, ai_category: 'camera_report', suggested_action: 'draft_response', kb_reference: 'none' });
|
||||
expect(cameraReport.tags).toContain('kb-gap');
|
||||
|
||||
const localGroup = planZammadActions({ ...base, ai_category: 'local_group_request', suggested_action: 'draft_response', kb_reference: 'none' });
|
||||
expect(localGroup.tags).toContain('kb-gap');
|
||||
});
|
||||
|
||||
it('does not tag kb-gap when a kb_reference was cited', () => {
|
||||
const plan = planZammadActions({ ...base, ai_category: 'donation', suggested_action: 'draft_response', kb_reference: 'donations.md' });
|
||||
expect(plan.tags).not.toContain('kb-gap');
|
||||
});
|
||||
|
||||
it('does not tag kb-gap for categories that are not KB-grounded', () => {
|
||||
const plan = planZammadActions({ ...base, ai_category: 'technical_bug', suggested_action: 'draft_response', kb_reference: 'none' });
|
||||
expect(plan.tags).not.toContain('kb-gap');
|
||||
});
|
||||
|
||||
it('prefixes risk_flags as stackable tags', () => {
|
||||
const plan = planZammadActions({ ...base, risk_flags: ['abusive_or_threatening'] });
|
||||
expect(plan.tags).toContain('risk:abusive_or_threatening');
|
||||
});
|
||||
|
||||
it('always includes the ai_category ticket attribute', () => {
|
||||
const plan = planZammadActions({ ...base, ai_category: 'donation' });
|
||||
expect(plan.ticketAttribute).toEqual({ ai_category: 'donation' });
|
||||
});
|
||||
|
||||
it('does not route non-media categories to the Media group', () => {
|
||||
const plan = planZammadActions({ ...base, ai_category: 'donation' });
|
||||
expect(plan.groupOverride).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('screenContactSubmission', () => {
|
||||
it('sets ticket fields, applies tags, writes the shared draft, and still leaves an internal note summary', async () => {
|
||||
const deps = makeDeps({ ...base, ai_category: 'local_group_request', suggested_action: 'draft_response', suggested_reply: 'Here is how to start a group...', internal_note: 'Wants to start a group in Ohio.' });
|
||||
|
||||
await screenContactSubmission(
|
||||
{ ticketId: 1, topic: 'local-groups', subject: 's', message: 'm', senderEmailDomain: '', replyTo: 'jane@example.com' },
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(deps.setTicketFields).toHaveBeenCalledWith(1, { ai_category: 'local_group_request' });
|
||||
expect(deps.upsertSharedDraft).toHaveBeenCalledWith(1, 'Here is how to start a group...', { to: 'jane@example.com', cc: undefined });
|
||||
expect(deps.addInternalNote).toHaveBeenCalledTimes(1);
|
||||
expect(deps.addInternalNote.mock.calls[0][1]).toContain('Wants to start a group in Ohio.');
|
||||
const appliedTags = deps.addTag.mock.calls.map(c => c[1]);
|
||||
expect(appliedTags).toContain('ai-screened');
|
||||
expect(appliedTags).not.toContain('draft-fallback');
|
||||
});
|
||||
|
||||
it('returns the classification result and plan for the caller to log/inspect', async () => {
|
||||
const deps = makeDeps({ ...base, ai_category: 'local_group_request', suggested_action: 'draft_response', suggested_reply: 'Draft text' });
|
||||
|
||||
const outcome = await screenContactSubmission(
|
||||
{ ticketId: 1, topic: 'local-groups', subject: 's', message: 'm', senderEmailDomain: '', replyTo: 'jane@example.com' },
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(outcome.result.ai_category).toBe('local_group_request');
|
||||
expect(outcome.plan.sharedDraft).not.toBeNull();
|
||||
expect(outcome.appliedTags).toContain('ai-screened');
|
||||
expect(outcome.noteWritten).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to an internal note with a draft-fallback tag when the shared draft write fails', async () => {
|
||||
const deps = makeDeps({ ...base, ai_category: 'local_group_request', suggested_action: 'draft_response', suggested_reply: 'Draft text' });
|
||||
deps.upsertSharedDraft.mockImplementation(async () => { throw new Error('shared_drafts not enabled on this group'); });
|
||||
|
||||
await screenContactSubmission(
|
||||
{ ticketId: 2, topic: 'local-groups', subject: 's', message: 'm', senderEmailDomain: '', replyTo: 'jane@example.com' },
|
||||
deps,
|
||||
);
|
||||
|
||||
const appliedTags = deps.addTag.mock.calls.map(c => c[1]);
|
||||
expect(appliedTags).toContain('draft-fallback');
|
||||
expect(deps.addInternalNote).toHaveBeenCalledTimes(1);
|
||||
const noteBody = deps.addInternalNote.mock.calls[0][1];
|
||||
expect(noteBody).toContain('Draft text');
|
||||
});
|
||||
|
||||
it('includes priority/group in the single ticket-fields update for a big media inquiry', async () => {
|
||||
const deps = makeDeps({ ...base, ai_category: 'media_press', media_tier: 'big_media', suggested_action: 'escalate_urgent' });
|
||||
|
||||
await screenContactSubmission(
|
||||
{ ticketId: 3, topic: 'questions-comments', subject: 's', message: 'm', senderEmailDomain: '', replyTo: 'jane@example.com' },
|
||||
deps,
|
||||
);
|
||||
|
||||
const fields = deps.setTicketFields.mock.calls[0][1];
|
||||
expect(fields.ai_category).toBe('media_press');
|
||||
expect(fields.priority).toBe('3 high');
|
||||
expect(fields.group).toBe('Media');
|
||||
});
|
||||
|
||||
it('sets low priority and pending close for spam_bounce', async () => {
|
||||
const deps = makeDeps({ ...base, ai_category: 'spam_bounce', suggested_action: 'auto_delete' });
|
||||
|
||||
await screenContactSubmission(
|
||||
{ ticketId: 4, topic: 'questions-comments', subject: 's', message: 'm', senderEmailDomain: '', replyTo: 'jane@example.com' },
|
||||
deps,
|
||||
);
|
||||
|
||||
const fields = deps.setTicketFields.mock.calls[0][1];
|
||||
expect(fields.priority).toBe('1 low');
|
||||
expect(fields.state).toBe('pending close');
|
||||
});
|
||||
|
||||
it('propagates a rejection from the AI client without swallowing it', async () => {
|
||||
const zammadClient = {
|
||||
addTag: mock(async () => {}),
|
||||
addInternalNote: mock(async () => {}),
|
||||
setTicketFields: mock(async () => {}),
|
||||
upsertSharedDraft: mock(async () => {}),
|
||||
} as any;
|
||||
const aiClient = { screen: mock(async () => { throw new Error('OpenAI down'); }) } as any;
|
||||
|
||||
await expect(screenContactSubmission(
|
||||
{ ticketId: 5, topic: 'media', subject: 's', message: 'm', senderEmailDomain: '', replyTo: 'jane@example.com' },
|
||||
{ aiClient, zammadClient },
|
||||
)).rejects.toThrow('OpenAI down');
|
||||
|
||||
expect(zammadClient.setTicketFields).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { TOPIC_GROUP_MAP, type ContactTopic, type ZammadClient } from './ZammadClient';
|
||||
import type { AiScreeningClient, ScreeningResult } from './AiScreeningClient';
|
||||
import { endOfWorkdayEasternIso } from './BusinessHours';
|
||||
|
||||
const KB_GROUNDED_CATEGORIES = new Set(['donation', 'api_data', 'other', 'camera_correction', 'camera_report', 'local_group_request']);
|
||||
|
||||
export interface ZammadActionPlan {
|
||||
ticketAttribute: { ai_category: string };
|
||||
tags: string[];
|
||||
sharedDraft: { body: string; type: 'email'; internal: false } | null;
|
||||
state: 'pending close' | null;
|
||||
pendingTime: string | null;
|
||||
noteBody: string;
|
||||
priorityUpdate: '1 low' | '3 high' | null;
|
||||
groupOverride: string | null;
|
||||
}
|
||||
|
||||
export function planZammadActions(result: ScreeningResult): ZammadActionPlan {
|
||||
const tags = ['ai-screened'];
|
||||
for (const flag of result.risk_flags) tags.push(`risk:${flag}`);
|
||||
|
||||
if (result.ai_category === 'technical_bug') tags.push('technical-bug');
|
||||
if (result.ai_category === 'camera_correction') tags.push('camera-correction');
|
||||
if (result.ai_category === 'media_press') {
|
||||
tags.push('media');
|
||||
if (result.media_tier === 'big_media') tags.push('media-big');
|
||||
if (result.media_tier === 'small_media') tags.push('media-small');
|
||||
}
|
||||
if (result.ai_category === 'legal') tags.push('legal');
|
||||
if (KB_GROUNDED_CATEGORIES.has(result.ai_category) && result.kb_reference === 'none') {
|
||||
tags.push('kb-gap');
|
||||
}
|
||||
|
||||
const sharedDraft = result.suggested_action === 'draft_response'
|
||||
? { body: result.suggested_reply, type: 'email' as const, internal: false as const }
|
||||
: null;
|
||||
|
||||
const priorityUpdate = result.suggested_action === 'escalate_urgent'
|
||||
? '3 high' as const
|
||||
: (result.suggested_action === 'auto_delete' || result.suggested_action === 'scheduled_close')
|
||||
? '1 low' as const
|
||||
: null;
|
||||
|
||||
// Sender may have picked the wrong topic on the form (e.g. contacted General Support by
|
||||
// mistake) — route media inquiries to the Media group regardless of what they chose.
|
||||
const groupOverride = result.ai_category === 'media_press' ? TOPIC_GROUP_MAP.media : null;
|
||||
|
||||
const isPendingClose = result.suggested_action === 'scheduled_close' || result.suggested_action === 'auto_delete';
|
||||
const state = isPendingClose ? 'pending close' as const : null;
|
||||
const pendingTime = isPendingClose ? endOfWorkdayEasternIso() : null;
|
||||
|
||||
return {
|
||||
ticketAttribute: { ai_category: result.ai_category },
|
||||
tags,
|
||||
sharedDraft,
|
||||
state,
|
||||
pendingTime,
|
||||
// Always populated (internal_note is required and non-empty per the schema) — even
|
||||
// draft_response categories get a note now, so an agent reviewing a shared draft still
|
||||
// gets a quick summary of the AI's reasoning without opening the compose box.
|
||||
noteBody: result.internal_note,
|
||||
priorityUpdate,
|
||||
groupOverride,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ScreenContactSubmissionInput {
|
||||
ticketId: number;
|
||||
topic: ContactTopic;
|
||||
subject: string;
|
||||
message: string;
|
||||
senderEmailDomain: string;
|
||||
// Recipient(s) for the shared draft reply. replyTo is the customer's address (used whenever
|
||||
// there's nothing richer to reply-all to); replyCc carries any other correspondents on the
|
||||
// ticket so the draft doesn't drop people who were already on the thread.
|
||||
replyTo: string;
|
||||
replyCc?: string;
|
||||
}
|
||||
|
||||
export interface ScreenContactSubmissionDeps {
|
||||
aiClient: AiScreeningClient;
|
||||
zammadClient: ZammadClient;
|
||||
}
|
||||
|
||||
export interface ScreenContactSubmissionResult {
|
||||
result: ScreeningResult;
|
||||
plan: ZammadActionPlan;
|
||||
appliedTags: string[];
|
||||
noteWritten: boolean;
|
||||
}
|
||||
|
||||
export async function screenContactSubmission(
|
||||
input: ScreenContactSubmissionInput,
|
||||
deps: ScreenContactSubmissionDeps,
|
||||
): Promise<ScreenContactSubmissionResult> {
|
||||
const result = await deps.aiClient.screen({
|
||||
topic: input.topic,
|
||||
subject: input.subject,
|
||||
message: input.message,
|
||||
senderEmailDomain: input.senderEmailDomain,
|
||||
});
|
||||
|
||||
const plan = planZammadActions(result);
|
||||
|
||||
const ticketFields: Record<string, unknown> = { ai_category: plan.ticketAttribute.ai_category };
|
||||
if (plan.priorityUpdate) ticketFields.priority = plan.priorityUpdate;
|
||||
if (plan.state) ticketFields.state = plan.state;
|
||||
if (plan.pendingTime) ticketFields.pending_time = plan.pendingTime;
|
||||
if (plan.groupOverride) ticketFields.group = plan.groupOverride;
|
||||
await deps.zammadClient.setTicketFields(input.ticketId, ticketFields);
|
||||
|
||||
const tags = [...plan.tags];
|
||||
let noteBody = plan.noteBody
|
||||
? `🤖 AI Triage:\n\n${plan.noteBody}`
|
||||
: null;
|
||||
|
||||
if (plan.sharedDraft) {
|
||||
try {
|
||||
await deps.zammadClient.upsertSharedDraft(input.ticketId, plan.sharedDraft.body, {
|
||||
to: input.replyTo,
|
||||
cc: input.replyCc,
|
||||
});
|
||||
} catch {
|
||||
tags.push('draft-fallback');
|
||||
noteBody = `🤖 AI-drafted reply (shared draft failed — unreviewed, edit/approve before sending):\n\n${plan.sharedDraft.body}` +
|
||||
(plan.noteBody ? `\n\n---\n${plan.noteBody}` : '');
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(tags.map(tag => deps.zammadClient.addTag(input.ticketId, tag)));
|
||||
|
||||
if (noteBody) {
|
||||
await deps.zammadClient.addInternalNote(input.ticketId, noteBody);
|
||||
}
|
||||
|
||||
return { result, plan, appliedTags: tags, noteWritten: Boolean(noteBody) };
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { existsSync, readdirSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const KB_DIR = join(__dirname, '../../kb');
|
||||
|
||||
export interface KbDocument {
|
||||
filename: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function loadKnowledgeBase(dir: string = KB_DIR): KbDocument[] {
|
||||
if (!existsSync(dir)) return [];
|
||||
return readdirSync(dir)
|
||||
// CLAUDE.md documents this directory's conventions for engineers — it's not grounding
|
||||
// content and must never be citable as a kb_reference.
|
||||
.filter(f => f.endsWith('.md') && f.toUpperCase() !== 'CLAUDE.MD')
|
||||
.sort()
|
||||
.map(filename => ({ filename, content: readFileSync(join(dir, filename), 'utf-8') }));
|
||||
}
|
||||
|
||||
export function formatKnowledgeBaseForPrompt(docs: KbDocument[]): string {
|
||||
if (docs.length === 0) {
|
||||
return [
|
||||
'## Knowledge base',
|
||||
'',
|
||||
'No knowledge base documents are currently loaded. Always set kb_reference to "none". For',
|
||||
'donation, api_data, camera_correction, and other, this means you cannot ground a reply',
|
||||
'in policy yet — set suggested_action to internal_note_only rather than inventing an answer.',
|
||||
].join('\n');
|
||||
}
|
||||
const sections = docs.map(d => `### ${d.filename}\n\n${d.content}`).join('\n\n---\n\n');
|
||||
return `## Knowledge base\n\nCite the filename of the document you draw from as kb_reference. If none of these\ndocuments cover the question, set kb_reference to "none".\n\n${sections}`;
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { describe, it, expect, afterEach, mock } from 'bun:test';
|
||||
import { ZammadClient } from './ZammadClient';
|
||||
|
||||
describe('ZammadClient.createTicket', () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('creates a customer (when none exists) then the ticket, and returns its id', async () => {
|
||||
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
||||
global.fetch = mock(async (url: string, init?: RequestInit) => {
|
||||
calls.push({ url, init });
|
||||
if (url.includes('/users/search')) {
|
||||
return new Response(JSON.stringify([]));
|
||||
}
|
||||
if (url.includes('/users') && init?.method === 'POST') {
|
||||
return new Response(JSON.stringify({ id: 42 }));
|
||||
}
|
||||
if (url.includes('/tickets') && init?.method === 'POST') {
|
||||
return new Response(JSON.stringify({ id: 99 }));
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const client = new ZammadClient();
|
||||
const result = await client.createTicket({
|
||||
name: 'Jane Doe',
|
||||
email: 'jane@example.com',
|
||||
topic: 'media',
|
||||
subject: 'Story inquiry',
|
||||
message: 'Hello',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: 99 });
|
||||
|
||||
const ticketCall = calls.find(c => c.url.includes('/tickets') && c.init?.method === 'POST');
|
||||
const body = JSON.parse(ticketCall!.init!.body as string);
|
||||
expect(body.priority).toBe('2 normal');
|
||||
expect(body.customer_id).toBe(42);
|
||||
});
|
||||
|
||||
it('throws a descriptive error when ticket creation fails', async () => {
|
||||
global.fetch = mock(async (url: string) => {
|
||||
if (url.includes('/users/search')) return new Response(JSON.stringify([{ id: 1, email: 'jane@example.com' }]));
|
||||
if (url.includes('/tickets')) return new Response('boom', { status: 500 });
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const client = new ZammadClient();
|
||||
await expect(client.createTicket({
|
||||
name: 'Jane Doe',
|
||||
email: 'jane@example.com',
|
||||
topic: 'app-support',
|
||||
subject: 'Bug',
|
||||
message: 'It broke',
|
||||
})).rejects.toThrow('Zammad ticket creation failed: 500 boom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ZammadClient.addTag', () => {
|
||||
const originalFetch = global.fetch;
|
||||
afterEach(() => { global.fetch = originalFetch; });
|
||||
|
||||
it('POSTs to /api/v1/tags/add with the tag under "item" (Zammad ignores "tag" and leaves it nil)', async () => {
|
||||
let captured: { url: string; body: any } | undefined;
|
||||
global.fetch = mock(async (url: string, init?: RequestInit) => {
|
||||
captured = { url, body: JSON.parse(init!.body as string) };
|
||||
return new Response('{}');
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const client = new ZammadClient();
|
||||
await client.addTag(99, 'ai-screened');
|
||||
|
||||
expect(captured!.url).toContain('/api/v1/tags/add');
|
||||
expect(captured!.body).toEqual({ item: 'ai-screened', object: 'Ticket', o_id: 99 });
|
||||
});
|
||||
|
||||
it('throws a descriptive error on failure', async () => {
|
||||
global.fetch = mock(async () => new Response('nope', { status: 422 })) as unknown as typeof fetch;
|
||||
const client = new ZammadClient();
|
||||
await expect(client.addTag(99, 'ai-screened')).rejects.toThrow('Zammad tag creation failed for tag "ai-screened" on ticket 99: 422 nope');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ZammadClient.addInternalNote', () => {
|
||||
const originalFetch = global.fetch;
|
||||
afterEach(() => { global.fetch = originalFetch; });
|
||||
|
||||
it('POSTs to /api/v1/ticket_articles as an internal note', async () => {
|
||||
let captured: { url: string; body: any } | undefined;
|
||||
global.fetch = mock(async (url: string, init?: RequestInit) => {
|
||||
captured = { url, body: JSON.parse(init!.body as string) };
|
||||
return new Response('{}');
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const client = new ZammadClient();
|
||||
await client.addInternalNote(99, 'note body');
|
||||
|
||||
expect(captured!.url).toContain('/api/v1/ticket_articles');
|
||||
expect(captured!.body).toEqual({ ticket_id: 99, body: 'note body', type: 'note', internal: true });
|
||||
});
|
||||
|
||||
it('throws a descriptive error on failure', async () => {
|
||||
global.fetch = mock(async () => new Response('nope', { status: 500 })) as unknown as typeof fetch;
|
||||
const client = new ZammadClient();
|
||||
await expect(client.addInternalNote(99, 'note body')).rejects.toThrow('Zammad internal note creation failed: 500 nope');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ZammadClient.setTicketFields', () => {
|
||||
const originalFetch = global.fetch;
|
||||
afterEach(() => { global.fetch = originalFetch; });
|
||||
|
||||
it('PUTs to /api/v1/tickets/{id} with the given fields, whatever they are', async () => {
|
||||
let captured: { url: string; method?: string; body: any } | undefined;
|
||||
global.fetch = mock(async (url: string, init?: RequestInit) => {
|
||||
captured = { url, method: init?.method, body: JSON.parse(init!.body as string) };
|
||||
return new Response('{}');
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const client = new ZammadClient();
|
||||
await client.setTicketFields(99, {
|
||||
ai_category: 'media_press',
|
||||
priority: '3 high',
|
||||
state: 'pending close',
|
||||
pending_time: '2026-07-16T21:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(captured!.url).toContain('/api/v1/tickets/99');
|
||||
expect(captured!.method).toBe('PUT');
|
||||
expect(captured!.body).toEqual({
|
||||
ai_category: 'media_press',
|
||||
priority: '3 high',
|
||||
state: 'pending close',
|
||||
pending_time: '2026-07-16T21:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws a descriptive error on failure', async () => {
|
||||
global.fetch = mock(async () => new Response('nope', { status: 500 })) as unknown as typeof fetch;
|
||||
const client = new ZammadClient();
|
||||
await expect(client.setTicketFields(99, { priority: '3 high' })).rejects.toThrow('Zammad ticket fields update failed: 500 nope');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ZammadClient.upsertSharedDraft', () => {
|
||||
const originalFetch = global.fetch;
|
||||
afterEach(() => { global.fetch = originalFetch; });
|
||||
|
||||
it('PUTs to /api/v1/tickets/{id}/shared_draft with the body nested under new_article (Zammad strips top-level keys)', async () => {
|
||||
let captured: { url: string; method?: string; body: any } | undefined;
|
||||
global.fetch = mock(async (url: string, init?: RequestInit) => {
|
||||
captured = { url, method: init?.method, body: JSON.parse(init!.body as string) };
|
||||
return new Response('{}');
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const client = new ZammadClient();
|
||||
await client.upsertSharedDraft(99, 'Draft reply text', { to: 'jane@example.com', cc: 'other@example.com' });
|
||||
|
||||
expect(captured!.url).toContain('/api/v1/tickets/99/shared_draft');
|
||||
expect(captured!.method).toBe('PUT');
|
||||
expect(captured!.body).toEqual({
|
||||
new_article: { body: 'Draft reply text', type: 'email', internal: false, to: 'jane@example.com', cc: 'other@example.com' },
|
||||
ticket_attributes: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults cc to an empty string when not provided', async () => {
|
||||
let captured: { body: any } | undefined;
|
||||
global.fetch = mock(async (_url: string, init?: RequestInit) => {
|
||||
captured = { body: JSON.parse(init!.body as string) };
|
||||
return new Response('{}');
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const client = new ZammadClient();
|
||||
await client.upsertSharedDraft(99, 'Draft reply text', { to: 'jane@example.com' });
|
||||
|
||||
expect(captured!.body.new_article.cc).toBe('');
|
||||
});
|
||||
|
||||
it('throws a descriptive error on failure', async () => {
|
||||
global.fetch = mock(async () => new Response('nope', { status: 422 })) as unknown as typeof fetch;
|
||||
const client = new ZammadClient();
|
||||
await expect(client.upsertSharedDraft(99, 'text', { to: 'jane@example.com' })).rejects.toThrow('Zammad shared draft update failed: 422 nope');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ZammadClient.getTicket', () => {
|
||||
const originalFetch = global.fetch;
|
||||
afterEach(() => { global.fetch = originalFetch; });
|
||||
|
||||
it('GETs /api/v1/tickets/{id} and returns the parsed ticket', async () => {
|
||||
global.fetch = mock(async () =>
|
||||
new Response(JSON.stringify({ id: 99, title: 'Story inquiry', group: 'Media', customer_id: 42 }))
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
const client = new ZammadClient();
|
||||
const ticket = await client.getTicket(99);
|
||||
|
||||
expect(ticket).toEqual({ id: 99, title: 'Story inquiry', group: 'Media', customer_id: 42 });
|
||||
});
|
||||
|
||||
it('throws a descriptive error on failure', async () => {
|
||||
global.fetch = mock(async () => new Response('nope', { status: 404 })) as unknown as typeof fetch;
|
||||
const client = new ZammadClient();
|
||||
await expect(client.getTicket(99)).rejects.toThrow('Zammad ticket fetch failed: 404 nope');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ZammadClient.getTicketArticles', () => {
|
||||
const originalFetch = global.fetch;
|
||||
afterEach(() => { global.fetch = originalFetch; });
|
||||
|
||||
it('GETs /api/v1/ticket_articles/by_ticket/{id} and returns the parsed articles', async () => {
|
||||
const articles = [{ body: '<p>Hello</p>', content_type: 'text/html' }];
|
||||
global.fetch = mock(async (url: string) => {
|
||||
expect(url).toContain('/api/v1/ticket_articles/by_ticket/99');
|
||||
return new Response(JSON.stringify(articles));
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const client = new ZammadClient();
|
||||
const result = await client.getTicketArticles(99);
|
||||
|
||||
expect(result).toEqual(articles);
|
||||
});
|
||||
|
||||
it('throws a descriptive error on failure', async () => {
|
||||
global.fetch = mock(async () => new Response('nope', { status: 500 })) as unknown as typeof fetch;
|
||||
const client = new ZammadClient();
|
||||
await expect(client.getTicketArticles(99)).rejects.toThrow('Zammad ticket articles fetch failed: 500 nope');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ZammadClient.getUser', () => {
|
||||
const originalFetch = global.fetch;
|
||||
afterEach(() => { global.fetch = originalFetch; });
|
||||
|
||||
it('GETs /api/v1/users/{id} and returns the parsed user', async () => {
|
||||
global.fetch = mock(async (url: string) => {
|
||||
expect(url).toContain('/api/v1/users/42');
|
||||
return new Response(JSON.stringify({ email: 'jane@nytimes.com' }));
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const client = new ZammadClient();
|
||||
const user = await client.getUser(42);
|
||||
|
||||
expect(user).toEqual({ email: 'jane@nytimes.com' });
|
||||
});
|
||||
|
||||
it('throws a descriptive error on failure', async () => {
|
||||
global.fetch = mock(async () => new Response('nope', { status: 404 })) as unknown as typeof fetch;
|
||||
const client = new ZammadClient();
|
||||
await expect(client.getUser(42)).rejects.toThrow('Zammad user fetch failed: 404 nope');
|
||||
});
|
||||
});
|
||||
@@ -23,11 +23,12 @@ export const ContactMessageBodySchema = Type.Object({
|
||||
subject: Type.String({ minLength: 1 }),
|
||||
message: Type.String({ minLength: 1 }),
|
||||
turnstileToken: Type.String({ minLength: 1 }),
|
||||
aiScreeningOptOut: Type.Optional(Type.Boolean({ default: false })),
|
||||
});
|
||||
|
||||
export type ContactMessageBody = Static<typeof ContactMessageBodySchema>;
|
||||
|
||||
const TOPIC_GROUP_MAP: Record<ContactTopic, string> = {
|
||||
export const TOPIC_GROUP_MAP: Record<ContactTopic, string> = {
|
||||
'website-support': 'Website Support',
|
||||
'app-support': 'App Support',
|
||||
'local-groups': 'Local Groups',
|
||||
@@ -77,7 +78,7 @@ export class ZammadClient {
|
||||
return user.id;
|
||||
}
|
||||
|
||||
async createTicket(payload: CreateTicketPayload): Promise<void> {
|
||||
async createTicket(payload: CreateTicketPayload): Promise<{ id: number }> {
|
||||
const { name, email, topic, subject, message } = payload;
|
||||
const group = TOPIC_GROUP_MAP[topic];
|
||||
|
||||
@@ -86,7 +87,7 @@ export class ZammadClient {
|
||||
const body = JSON.stringify({
|
||||
title: subject,
|
||||
group,
|
||||
priority: topic === 'media' ? '3 high' : '2 normal',
|
||||
priority: '2 normal',
|
||||
customer_id: customerId,
|
||||
article: {
|
||||
subject,
|
||||
@@ -111,5 +112,110 @@ export class ZammadClient {
|
||||
const text = await response.text();
|
||||
throw new Error(`Zammad ticket creation failed: ${response.status} ${text}`);
|
||||
}
|
||||
const ticket = await response.json() as { id: number };
|
||||
return { id: ticket.id };
|
||||
}
|
||||
|
||||
async getTicket(ticketId: number): Promise<{ id: number; title: string; group: string; customer_id: number }> {
|
||||
const response = await fetch(`${ZAMMAD_URL}/api/v1/tickets/${ticketId}`, {
|
||||
headers: { 'Authorization': `Token token=${ZAMMAD_TOKEN}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Zammad ticket fetch failed: ${response.status} ${text}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async getTicketArticles(ticketId: number): Promise<Array<{ body: string; content_type: string; from: string; to: string; cc: string; sender: string }>> {
|
||||
const response = await fetch(`${ZAMMAD_URL}/api/v1/ticket_articles/by_ticket/${ticketId}`, {
|
||||
headers: { 'Authorization': `Token token=${ZAMMAD_TOKEN}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Zammad ticket articles fetch failed: ${response.status} ${text}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async getUser(userId: number): Promise<{ email: string }> {
|
||||
const response = await fetch(`${ZAMMAD_URL}/api/v1/users/${userId}`, {
|
||||
headers: { 'Authorization': `Token token=${ZAMMAD_TOKEN}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Zammad user fetch failed: ${response.status} ${text}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async addTag(ticketId: number, tag: string): Promise<void> {
|
||||
const response = await fetch(`${ZAMMAD_URL}/api/v1/tags/add`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Token token=${ZAMMAD_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
// Zammad's tag-add endpoint reads the tag name from "item", not "tag" — sending the
|
||||
// wrong key leaves it nil server-side and crashes Zammad's own tag_add with an
|
||||
// unhandled NoMethodError (500) rather than a clean validation error.
|
||||
body: JSON.stringify({ item: tag, object: 'Ticket', o_id: ticketId }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Zammad tag creation failed for tag "${tag}" on ticket ${ticketId}: ${response.status} ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
async addInternalNote(ticketId: number, body: string): Promise<void> {
|
||||
const response = await fetch(`${ZAMMAD_URL}/api/v1/ticket_articles`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Token token=${ZAMMAD_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ ticket_id: ticketId, body, type: 'note', internal: true }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Zammad internal note creation failed: ${response.status} ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
async setTicketFields(ticketId: number, fields: Record<string, unknown>): Promise<void> {
|
||||
const response = await fetch(`${ZAMMAD_URL}/api/v1/tickets/${ticketId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Token token=${ZAMMAD_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(fields),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Zammad ticket fields update failed: ${response.status} ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
async upsertSharedDraft(ticketId: number, body: string, recipients: { to: string; cc?: string }): Promise<void> {
|
||||
const response = await fetch(`${ZAMMAD_URL}/api/v1/tickets/${ticketId}/shared_draft`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Token token=${ZAMMAD_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
// TicketSharedDraftZoomController#draft_params only permits nested "new_article" /
|
||||
// "ticket_attributes" keys (params.permit ticket_attributes: {}, new_article: {}) —
|
||||
// top-level body/type/internal are silently stripped by Rails strong params, which
|
||||
// creates/updates the draft with an empty article and no error at all.
|
||||
body: JSON.stringify({
|
||||
new_article: { body, type: 'email', internal: false, to: recipients.to, cc: recipients.cc ?? '' },
|
||||
ticket_attributes: {},
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Zammad shared draft update failed: ${response.status} ${text}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user