@indemn/cli 1.7.8 → 1.7.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  import type { IndemnClient } from './client.js';
2
2
  import { CliError, EXIT } from '../core/runtime.js';
3
3
 
4
- /** Paths of the campaigns service the CLI may read. Everything else, and every write, is refused. */
4
+ /** Paths of the campaigns service the CLI may read. Everything else is refused. */
5
5
  const READABLE = [
6
6
  /^\/campaigns$/,
7
7
  /^\/campaigns\/[\w-]+$/,
@@ -11,12 +11,44 @@ const READABLE = [
11
11
  /^\/templates\/[\w-]+\/versions$/,
12
12
  /^\/template-skills$/,
13
13
  /^\/template-skills\/[\w-]+$/,
14
+ /^\/books$/,
15
+ /^\/books\/[\w-]+$/,
14
16
  ];
15
17
 
18
+ type WriteMethod = 'POST' | 'PUT' | 'PATCH';
19
+ /** What the CLI may change: campaigns and their drafts (touchpoints live there), publish and status, templates
20
+ * (each change a new version), template skills and books. Never sending or resolving anything, book runs,
21
+ * uploads, test ticks or enrollments, and no deletes: those act on real customers or cannot be undone. */
22
+ const WRITABLE: Array<[WriteMethod, RegExp]> = [
23
+ ['POST', /^\/campaigns$/],
24
+ ['PUT', /^\/campaigns\/[\w-]+\/draft$/],
25
+ ['POST', /^\/campaigns\/[\w-]+\/(publish|status)$/],
26
+ ['POST', /^\/templates$/],
27
+ ['POST', /^\/templates\/[\w-]+\/versions$/],
28
+ ['POST', /^\/template-skills$/],
29
+ ['PUT', /^\/template-skills\/[\w-]+$/],
30
+ ['POST', /^\/books$/],
31
+ ['PATCH', /^\/books\/[\w-]+\/source$/],
32
+ ['PUT', /^\/books\/[\w-]+\/(field-map|identity-keys|display)$/],
33
+ ];
34
+
35
+ export type CampaignStatus = 'draft' | 'running' | 'paused' | 'archived';
36
+ /** A touchpoint in a campaign's draft, found by its key. */
37
+ export type Touchpoint = { key: string } & Record<string, unknown>;
38
+ type Draft = { audience?: unknown; touchpoints?: Touchpoint[]; escalation_policy?: unknown; channel_policy?: unknown };
39
+ type Envelope = { total?: number; items?: Array<Record<string, unknown>> };
40
+ // A template version is whole: a change starts from the latest version's content.
41
+ const TEMPLATE_CONTENT = ['subject', 'body', 'html', 'uses_variables', 'selection_tags', 'skills', 'batch_row'];
42
+ const given = <T extends object>(o: T) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) as Partial<T>;
43
+ type Variable = { key: string; required?: boolean; example?: unknown };
44
+ // Templates declare the variables they read as {key, required, example}; given as keys, a key the template already
45
+ // declares keeps its entry.
46
+ const declare = (keys: string[] | undefined, current: Variable[] = []) => keys?.map((key) => current.find((v) => v.key === key) ?? { key });
47
+
16
48
  /**
17
- * Campaigns, READ-ONLY by design, through copilot's proxy to the campaigns
18
- * service. The CLI never publishes a campaign, sends a communication or
19
- * triggers a book sync; those act on real customers.
49
+ * Campaigns through copilot's proxy to the campaigns service: reads, and
50
+ * writes to configuration only (WRITABLE). The CLI never sends a
51
+ * communication, runs a book sync or deletes; those act on real customers.
20
52
  */
21
53
  export class CampaignsSDK {
22
54
  constructor(private client: IndemnClient) {}
@@ -26,6 +58,11 @@ export class CampaignsSDK {
26
58
  return this.client.copilotGet<T>(`/organization/:org_id/campaigns-service${path}`, query);
27
59
  }
28
60
 
61
+ private async write<T>(method: WriteMethod, path: string, body: unknown): Promise<T> {
62
+ if (!WRITABLE.some(([m, r]) => m === method && r.test(path))) throw new CliError(`Campaigns ${method} ${path} is not allowed from the CLI.`, EXIT.NEEDS_INPUT);
63
+ return this.client.copilotRequestRaw<T>(method, `/organization/:org_id/campaigns-service${path}`, body);
64
+ }
65
+
29
66
  list(filter: { status?: string; solutionId?: string; limit?: number; skip?: number } = {}): Promise<{ total?: number; items?: unknown[] }> {
30
67
  const q: Record<string, string> = { limit: String(Math.min(filter.limit ?? 50, 500)), skip: String(filter.skip ?? 0) };
31
68
  if (filter.status) q.status = filter.status;
@@ -57,4 +94,88 @@ export class CampaignsSDK {
57
94
  templateSkills(solutionId?: string): Promise<unknown> {
58
95
  return this.read('/template-skills', solutionId ? { solution_id: solutionId } : undefined);
59
96
  }
97
+
98
+ templateSkill(id: string): Promise<{ name?: string; instructions?: string }> {
99
+ return this.read(`/template-skills/${id}`);
100
+ }
101
+
102
+ books(filter: { solutionId?: string; limit?: number } = {}): Promise<Envelope> {
103
+ const q: Record<string, string> = { limit: String(Math.min(filter.limit ?? 50, 200)) }; // the service allows 200
104
+ if (filter.solutionId) q.solution_id = filter.solutionId;
105
+ return this.read('/books', q);
106
+ }
107
+
108
+ book(id: string): Promise<{ name?: string }> {
109
+ return this.read(`/books/${id}`);
110
+ }
111
+
112
+ createCampaign(input: { solution_id: string; book_id: string; name: string; campaign_type: string; entry?: string; closes?: string }): Promise<{ id: string }> {
113
+ return this.write('POST', '/campaigns', given(input));
114
+ }
115
+
116
+ /** Change parts of the draft; the rest of it (its touchpoints among it) stays as it is. */
117
+ async updateDraft(id: string, parts: Omit<Draft, 'touchpoints'>): Promise<Draft> {
118
+ const { draft } = await this.read<{ draft?: Draft }>(`/campaigns/${id}`);
119
+ return this.write('PUT', `/campaigns/${id}/draft`, { ...draft, ...given(parts) });
120
+ }
121
+
122
+ /** Add a touchpoint, or change the one with its key (only the fields given). A new one drafts unless told to
123
+ * send; a template given without a version is pinned to its latest. */
124
+ async setTouchpoint(id: string, touchpoint: Touchpoint): Promise<Draft> {
125
+ const { draft } = await this.read<{ draft?: Draft }>(`/campaigns/${id}`);
126
+ const current = draft?.touchpoints ?? [];
127
+ const existing = current.find((t) => t.key === touchpoint.key);
128
+ const tp: Touchpoint = { ...(existing ?? { terminal_action: 'draft' }), ...given(touchpoint), key: touchpoint.key };
129
+ if (typeof touchpoint.template_id === 'string' && touchpoint.template_version_n === undefined) {
130
+ tp.template_version_n = Number((await this.latestTemplateVersion(touchpoint.template_id))?.version_n ?? 1);
131
+ }
132
+ const touchpoints = existing ? current.map((t) => (t.key === tp.key ? tp : t)) : [...current, tp];
133
+ return this.write('PUT', `/campaigns/${id}/draft`, { ...draft, touchpoints });
134
+ }
135
+
136
+ publish(id: string, changeSummary?: string): Promise<unknown> {
137
+ return this.write('POST', `/campaigns/${id}/publish`, { change_summary: changeSummary ?? '' });
138
+ }
139
+
140
+ setStatus(id: string, status: CampaignStatus): Promise<unknown> {
141
+ return this.write('POST', `/campaigns/${id}/status`, { status });
142
+ }
143
+
144
+ createTemplate(input: Record<string, unknown> & { uses_variables?: string[] }): Promise<unknown> {
145
+ return this.write('POST', '/templates', given({ ...input, uses_variables: declare(input.uses_variables) }));
146
+ }
147
+
148
+ private async latestTemplateVersion(id: string): Promise<Record<string, unknown> | undefined> {
149
+ return (await this.read<Envelope>(`/templates/${id}/versions`)).items?.[0]; // newest first
150
+ }
151
+
152
+ /** A new version of the template: its latest one with the changes given. */
153
+ async updateTemplate(id: string, changes: Record<string, unknown> & { uses_variables?: string[] }): Promise<unknown> {
154
+ const latest = (await this.latestTemplateVersion(id)) ?? {};
155
+ const kept = Object.fromEntries(TEMPLATE_CONTENT.filter((k) => k in latest).map((k) => [k, latest[k]]));
156
+ return this.write('POST', `/templates/${id}/versions`, { ...kept, ...given({ ...changes, uses_variables: declare(changes.uses_variables, latest.uses_variables as Variable[]) }) });
157
+ }
158
+
159
+ createTemplateSkill(input: { solution_id?: string; key: string; name: string; instructions: string }): Promise<unknown> {
160
+ return this.write('POST', '/template-skills', input);
161
+ }
162
+
163
+ async updateTemplateSkill(id: string, changes: { name?: string; instructions?: string }): Promise<unknown> {
164
+ const skill = await this.templateSkill(id);
165
+ return this.write('PUT', `/template-skills/${id}`, { name: skill.name, instructions: skill.instructions, ...given(changes) });
166
+ }
167
+
168
+ createBook(input: { solution_id: string; name: string; schema_id?: string; source?: unknown }): Promise<{ id: string }> {
169
+ return this.write('POST', '/books', given(input));
170
+ }
171
+
172
+ /** Change the parts of a book that are given, each its own call in the service. Keys are the book's schema
173
+ * keys; identity keys change only while the book has no records. */
174
+ async updateBook(id: string, changes: { source?: unknown; field_map?: unknown[]; identity_keys?: string[]; display?: unknown }): Promise<unknown> {
175
+ if (changes.source !== undefined) await this.write('PATCH', `/books/${id}/source`, { source: changes.source });
176
+ if (changes.field_map !== undefined) await this.write('PUT', `/books/${id}/field-map`, { field_map: changes.field_map });
177
+ if (changes.identity_keys !== undefined) await this.write('PUT', `/books/${id}/identity-keys`, { identity_keys: changes.identity_keys });
178
+ if (changes.display !== undefined) await this.write('PUT', `/books/${id}/display`, { display: changes.display });
179
+ return this.book(id);
180
+ }
60
181
  }
@@ -111,10 +111,15 @@ exports[`MCP tool list > gives every tool a title and annotations 1`] = `
111
111
  "platform_list",
112
112
  "platform_save",
113
113
  "promote_prod_call",
114
+ "publish_campaign",
114
115
  "reimport_kb_source",
115
116
  "remove_holiday",
116
117
  "run_evaluation",
117
118
  "run_simulation",
119
+ "save_book",
120
+ "save_campaign",
121
+ "save_campaign_template",
122
+ "save_template_skill",
118
123
  "save_ticket_rule",
119
124
  "scrape_url",
120
125
  "search_phone_numbers",
@@ -122,10 +127,12 @@ exports[`MCP tool list > gives every tool a title and annotations 1`] = `
122
127
  "set_agent_transcriber",
123
128
  "set_agent_voice",
124
129
  "set_business_hours",
130
+ "set_campaign_status",
125
131
  "set_config",
126
132
  "set_org_admin_settings",
127
133
  "set_org_timezone",
128
134
  "set_prompt",
135
+ "set_touchpoint",
129
136
  "simulation_status",
130
137
  "test_function",
131
138
  "transfer_agent_owner",
@@ -0,0 +1,165 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import { on, recordFetch, type RecordedCall, type Responder } from '../helpers/fetch-recorder.js';
3
+ import { cleanEnv } from '../helpers/config-home.js';
4
+ import { connectPair } from '../helpers/mcp-pair.js';
5
+ import { IndemnClient } from '../../src/sdk/client.js';
6
+ import { registerTools } from '../../src/mcp/tools.js';
7
+ import { TOOL_META } from '../../src/mcp/tool-meta.js';
8
+
9
+ const ORG = 'aaaaaaaaaaaaaaaaaaaaaaaa';
10
+ const solutionsOn = on('GET', `/organization/${ORG}/configurations`, { json: { enableSolutions: true } });
11
+ const approve = () => ({ action: 'accept' as const, content: { confirm: true } });
12
+ async function pair(elicit: () => { action: 'accept' | 'decline'; content?: Record<string, unknown> } = approve) {
13
+ cleanEnv();
14
+ const client = new IndemnClient({ apiKey: `ind_dev_${'a'.repeat(64)}`, orgId: ORG, environment: 'dev' });
15
+ return connectPair((server) => { registerTools(server, client); }, { elicit });
16
+ }
17
+ const fetches = (responders: Responder[]) => recordFetch([solutionsOn, ...responders]);
18
+ const writes = (calls: RecordedCall[]) => calls.filter((c) => c.method !== 'GET').map((c) => `${c.method} ${c.url.split('campaigns-service')[1]}`);
19
+ const draft = { audience: null, touchpoints: [], escalation_policy: {}, channel_policy: {} };
20
+ const campaign = { id: 'c1', name: 'GL renewals', status: 'draft', current_version_n: null, draft };
21
+
22
+ describe('campaign tools', () => {
23
+ let p: Awaited<ReturnType<typeof pair>> | undefined;
24
+ afterEach(async () => { await p?.close(); p = undefined; });
25
+
26
+ it('with Solutions off, reads and changes are refused before any card or call, saying how to turn it on', async () => {
27
+ const { calls } = recordFetch([on('GET', '/configurations', { json: { enableSolutions: false } })]);
28
+ p = await pair();
29
+ for (const [name, args] of [['get_campaigns', { what: 'list' }], ['save_campaign', { solution_id: 's1', book_id: 'b1', name: 'X', campaign_type: 'renewal' }]] as const) {
30
+ const res = await p.call(name, args);
31
+ expect(res.isError).toBe(true);
32
+ expect(String(res.body)).toMatch(/Solutions is not turned on.*set_org_admin_settings.*Nothing was changed/);
33
+ }
34
+ expect(p.questions).toHaveLength(0);
35
+ expect(calls.filter((c) => c.url.includes('campaigns-service'))).toHaveLength(0);
36
+ });
37
+
38
+ it('save_campaign without an id creates, asking first by name; confirm and empty fields never reach the service', async () => {
39
+ const { calls } = fetches([on('GET', '/campaigns-service/books/b1', { json: { id: 'b1', name: 'GL book' } }), on('POST', '/campaigns-service/campaigns', { json: campaign })]);
40
+ p = await pair();
41
+ const res = await p.call('save_campaign', { solution_id: 's1', book_id: 'b1', name: 'GL renewals', campaign_type: 'renewal', closes: '', confirm: true });
42
+ expect(res.isError).toBe(false);
43
+ expect(p.questions[0].message).toContain('create campaign "GL renewals" as a draft; it sends nothing until it is published and started');
44
+ expect(writes(calls)).toEqual(['POST /campaigns']);
45
+ expect(calls.find((c) => c.method === 'POST')!.body).toEqual({ solution_id: 's1', book_id: 'b1', name: 'GL renewals', campaign_type: 'renewal' });
46
+ });
47
+
48
+ it('a create missing what it needs is answered at once: no card, no call', async () => {
49
+ const { calls } = fetches([]);
50
+ p = await pair();
51
+ const res = await p.call('save_campaign', { name: 'GL renewals' });
52
+ expect(res.isError).toBe(true);
53
+ expect(String(res.body)).toContain('To create a campaign also give solution_id, book_id, campaign_type');
54
+ expect(p.questions).toHaveLength(0);
55
+ expect(writes(calls)).toEqual([]);
56
+ });
57
+
58
+ it('save_campaign with an id changes only the draft parts given, by name on the card', async () => {
59
+ const touchpoints = [{ key: 't1', channel: 'email' }];
60
+ const { calls } = fetches([
61
+ on('GET', '/campaigns-service/campaigns/c1', { json: { ...campaign, draft: { ...draft, touchpoints } } }),
62
+ on('PUT', '/campaigns-service/campaigns/c1/draft', (c) => ({ json: c.body })),
63
+ ]);
64
+ p = await pair();
65
+ const res = await p.call('save_campaign', { campaign_id: 'c1', audience: '{"field":"status","op":"eq","value":"active"}' });
66
+ expect(res.isError).toBe(false);
67
+ expect(p.questions[0].message).toContain('change the audience in the draft of campaign "GL renewals"');
68
+ expect(calls.find((c) => c.method === 'PUT')!.body).toEqual({ ...draft, touchpoints, audience: { field: 'status', op: 'eq', value: 'active' } });
69
+ });
70
+
71
+ it('set_touchpoint takes flat fields and rules as JSON text, and drafts unless told to send', async () => {
72
+ const { calls } = fetches([
73
+ on('GET', '/campaigns-service/campaigns/c1', { json: campaign }),
74
+ on('PUT', '/campaigns-service/campaigns/c1/draft', (c) => ({ json: c.body })),
75
+ ]);
76
+ p = await pair();
77
+ const res = await p.call('set_touchpoint', { campaign_id: 'c1', key: 't1_60', channel: 'email', when: '{"field":"derived.days_out","op":"lte","value":60}' });
78
+ expect(res.isError).toBe(false);
79
+ expect((calls.find((c) => c.method === 'PUT')!.body as typeof draft).touchpoints).toEqual([{ key: 't1_60', channel: 'email', terminal_action: 'draft', when: { field: 'derived.days_out', op: 'lte', value: 60 } }]);
80
+ expect(p.questions[0].message).toContain('touchpoint "t1_60" in the draft of campaign "GL renewals" (email)');
81
+ });
82
+
83
+ it('starting a campaign is its own approval and says it reaches customers; a decline changes nothing', async () => {
84
+ const { calls } = fetches([on('GET', '/campaigns-service/campaigns/c1', { json: campaign }), on('POST', '/status', { json: { ...campaign, status: 'running' } })]);
85
+ p = await pair(() => ({ action: 'decline' }));
86
+ await p.call('set_campaign_status', { campaign_id: 'c1', status: 'running' });
87
+ expect(p.questions[0].message).toMatch(/START campaign "GL renewals".*reach real customers/);
88
+ expect(writes(calls)).toEqual([]);
89
+ });
90
+
91
+ it("the service's refusal reaches the person as it is", async () => {
92
+ fetches([
93
+ on('GET', '/campaigns-service/campaigns/c1', { json: campaign }),
94
+ on('POST', '/status', { status: 409, json: { detail: 'cannot start a campaign that has never published a version' } }),
95
+ ]);
96
+ p = await pair();
97
+ const res = await p.call('set_campaign_status', { campaign_id: 'c1', status: 'running' });
98
+ expect(res.isError).toBe(true);
99
+ expect(String(res.body)).toContain('never published a version');
100
+ });
101
+
102
+ it('an id that names nothing gets no card', async () => {
103
+ const { calls } = fetches([on('GET', '/campaigns-service/campaigns/nope', { status: 404, json: { detail: 'not found' } })]);
104
+ p = await pair();
105
+ const res = await p.call('publish_campaign', { campaign_id: 'nope' });
106
+ expect(String(res.body)).toContain('Not found: campaign nope');
107
+ expect(p.questions).toHaveLength(0);
108
+ expect(writes(calls)).toEqual([]);
109
+ });
110
+
111
+ it('save_campaign_template with an id saves a new version from the latest one, named on the card', async () => {
112
+ const { calls } = fetches([
113
+ on('GET', '/campaigns-service/templates/tpl1/versions', { json: { items: [{ version_n: 1, subject: 'Renewal', body: 'Old', uses_variables: [] }] } }),
114
+ on('GET', '/campaigns-service/templates/tpl1', { json: { id: 'tpl1', name: 'GL reminder' } }),
115
+ on('POST', '/campaigns-service/templates/tpl1/versions', (c) => ({ json: { version_n: 2, ...(c.body as object) } })),
116
+ ]);
117
+ p = await pair();
118
+ const res = await p.call('save_campaign_template', { template_id: 'tpl1', body: 'New', subject: '' });
119
+ expect(res.isError).toBe(false);
120
+ expect(calls.find((c) => c.method === 'POST')!.body).toEqual({ subject: 'Renewal', body: 'New', uses_variables: [] }); // "" kept the subject
121
+ expect(p.questions[0].message).toContain('new version of template "GL reminder" (body)');
122
+ });
123
+
124
+ it('save_template_skill creates and changes, by name on the card', async () => {
125
+ const { calls } = fetches([
126
+ on('GET', '/campaigns-service/template-skills/sk1', { json: { id: 'sk1', name: 'Tone', instructions: 'Brief.' } }),
127
+ (c) => (c.method === 'GET' ? undefined : { json: { id: 'sk1' } }),
128
+ ]);
129
+ p = await pair();
130
+ await p.call('save_template_skill', { solution_id: 's1', key: 'tone', name: 'Tone', instructions: 'Brief.' });
131
+ await p.call('save_template_skill', { template_skill_id: 'sk1', instructions: 'Warm.' });
132
+ expect(writes(calls)).toEqual(['POST /template-skills', 'PUT /template-skills/sk1']);
133
+ expect(calls.filter((c) => c.method !== 'GET').map((c) => c.body)).toEqual([{ solution_id: 's1', key: 'tone', name: 'Tone', instructions: 'Brief.' }, { name: 'Tone', instructions: 'Warm.' }]);
134
+ expect(p.questions[1].message).toContain('change template skill "Tone" (instructions)');
135
+ });
136
+
137
+ it('save_book changes only the parts given; a create whose change is refused says the book exists', async () => {
138
+ const { calls } = fetches([
139
+ on('GET', '/campaigns-service/books/b1', { json: { id: 'b1', name: 'GL book' } }),
140
+ on('POST', '/campaigns-service/books', { json: { id: 'b2', name: 'New book' } }),
141
+ on('PUT', '/books/b2/display', { status: 400, json: { detail: 'display names keys absent from the schema: no.such' } }),
142
+ (c) => (c.method === 'GET' ? undefined : { json: { id: 'b1' } }),
143
+ ]);
144
+ p = await pair();
145
+ const res = await p.call('save_book', { book_id: 'b1', identity_keys: ['policy.number'], display: '{"primary":["policy.number"],"secondary":["insured.name"]}' });
146
+ expect(res.isError).toBe(false);
147
+ expect(p.questions[0].message).toContain('change book "GL book": identity keys to policy.number; display to primary policy.number; secondary insured.name.');
148
+ const made = await p.call('save_book', { solution_id: 's1', name: 'New book', display: '{"primary":["no.such"]}' });
149
+ expect(String(made.body)).toMatch(/The book was created \(id b2\), but .*no\.such.*do not create it again/);
150
+ expect(writes(calls)).toEqual(['PUT /books/b1/identity-keys', 'PUT /books/b1/display', 'POST /books', 'PUT /books/b2/display']);
151
+ });
152
+
153
+ it('a book change card shows the new values', () => {
154
+ expect(TOOL_META.save_book.describe!({ book_id: 'b1', source: { kind: 'upload' }, field_map: [{ source_path: 'Policy #', schema_key: 'policy.number' }, { source_path: 'Insured', schema_key: 'insured.name' }] }))
155
+ .toBe('This will change book b1: source to upload; field map to Policy # → policy.number, Insured → insured.name.');
156
+ });
157
+
158
+ it('get_campaigns reads books', async () => {
159
+ const { calls } = fetches([on('GET', '/campaigns-service/books', { json: { total: 1, items: [{ id: 'b1', name: 'GL book' }] } })]);
160
+ p = await pair();
161
+ const res = await p.call('get_campaigns', { what: 'books', solution_id: 's1' });
162
+ expect(res.isError).toBe(false);
163
+ expect(calls.some((c) => c.method === 'GET' && c.url.includes('/campaigns-service/books?') && c.url.includes('solution_id=s1'))).toBe(true);
164
+ });
165
+ });
@@ -17,7 +17,7 @@ describe('MCP tool list', () => {
17
17
  expect(typeof tool.annotations?.readOnlyHint, tool.name).toBe('boolean');
18
18
  }
19
19
  expect(tools.map((t) => t.name).sort()).toMatchSnapshot();
20
- expect(tools).toHaveLength(141); // +numbers // +set_agent_transcriber // +set_org_admin_settings // +remove_holiday // +create_sandbox_org
20
+ expect(tools).toHaveLength(148); // +7 campaign tools // +numbers // +set_agent_transcriber // +set_org_admin_settings // +remove_holiday // +create_sandbox_org
21
21
  const props = (name: string) => Object.keys((tools.find((t) => t.name === name)!.inputSchema as { properties?: object }).properties ?? {});
22
22
  for (const name of ['list_agents', 'delete_agent', 'set_prompt', 'list_org_members', 'list_channels', 'chat_with_agent']) {
23
23
  expect(props(name), name).toContain('org_id');
@@ -26,7 +26,7 @@ describe('MCP tool list', () => {
26
26
  expect(props(name), name).not.toContain('org_id');
27
27
  }
28
28
  // Writes that change what customers get or reach outside ask first on every host (a dry run, then confirm).
29
- for (const name of ['delete_agent', 'create_function', 'test_function', 'add_kb_data', 'scrape_url', 'upload_kb_file']) {
29
+ for (const name of ['delete_agent', 'create_function', 'test_function', 'add_kb_data', 'scrape_url', 'upload_kb_file', 'save_campaign', 'set_touchpoint', 'publish_campaign', 'set_campaign_status', 'save_campaign_template', 'save_template_skill', 'save_book']) {
30
30
  expect(props(name), name).toContain('confirm');
31
31
  }
32
32
  // The person chooses how much of a website is crawled: only scrape_url crawls, and it needs their choice.
@@ -1,14 +1,15 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
- import { readFileSync } from 'node:fs';
3
- import { on, recordFetch } from '../helpers/fetch-recorder.js';
2
+ import { on, recordFetch, type RecordedCall } from '../helpers/fetch-recorder.js';
4
3
  import { cleanEnv, useTempHome } from '../helpers/config-home.js';
5
4
  import { jwtExpiringIn } from '../helpers/jwt.js';
6
5
  import { IndemnClient } from '../../src/sdk/client.js';
7
6
  import { CampaignsSDK } from '../../src/sdk/campaigns.js';
8
7
 
9
8
  const ORG = 'aaaaaaaaaaaaaaaaaaaaaaaa';
9
+ const base = `/api/organization/${ORG}/campaigns-service`;
10
+ const seen = (calls: RecordedCall[]) => calls.map((c) => `${c.method} ${new URL(c.url).pathname.replace(base, '')}`);
10
11
 
11
- describe('campaigns are read-only', () => {
12
+ describe('campaigns: reads, and writes to configuration only', () => {
12
13
  let home: ReturnType<typeof useTempHome>;
13
14
  beforeEach(() => {
14
15
  cleanEnv();
@@ -18,26 +19,83 @@ describe('campaigns are read-only', () => {
18
19
  });
19
20
  afterEach(() => home.cleanup());
20
21
 
21
- it('has no write call in the campaigns module', () => {
22
- const src = readFileSync(new URL('../../src/sdk/campaigns.ts', import.meta.url), 'utf8');
23
- expect(src).not.toMatch(/copilot(Post|Put|Delete|RequestRaw|PostForm|Binary)/);
24
- });
25
-
26
- it('reads through the org proxy with GET only', async () => {
22
+ it('reads through the org proxy with GET, books included', async () => {
27
23
  const { calls } = recordFetch([on('GET', '/campaigns-service/', { json: { total: 0, limit: 50, skip: 0, items: [] } })]);
28
24
  const sdk = new CampaignsSDK(new IndemnClient());
29
- await sdk.list(); await sdk.detail('c1', 'forecast', 30); await sdk.templates(); await sdk.template('t1', true);
30
- expect(calls.map((c) => `${c.method} ${new URL(c.url).pathname}`)).toEqual([
31
- `GET /api/organization/${ORG}/campaigns-service/campaigns`,
32
- `GET /api/organization/${ORG}/campaigns-service/campaigns/c1/forecast`,
33
- `GET /api/organization/${ORG}/campaigns-service/templates`,
34
- `GET /api/organization/${ORG}/campaigns-service/templates/t1/versions`,
35
- ]);
25
+ await sdk.list(); await sdk.detail('c1', 'forecast', 30); await sdk.templates(); await sdk.template('t1', true); await sdk.books(); await sdk.book('b1');
26
+ expect(seen(calls)).toEqual(['GET /campaigns', 'GET /campaigns/c1/forecast', 'GET /templates', 'GET /templates/t1/versions', 'GET /books', 'GET /books/b1']);
36
27
  });
37
28
 
38
- it('refuses a path outside the readable list', async () => {
29
+ it('never sends, runs, uploads, ticks or deletes: those paths are refused before any call', async () => {
39
30
  const { calls } = recordFetch();
40
- await expect(new CampaignsSDK(new IndemnClient()).get('c1/../../communications/x/send')).rejects.toThrow(/not readable/);
31
+ const sdk = new CampaignsSDK(new IndemnClient());
32
+ await expect(sdk.get('c1/../../communications/x/send')).rejects.toThrow(/not readable/);
33
+ const write = (sdk as unknown as { write: (m: string, p: string, b?: unknown) => Promise<unknown> }).write.bind(sdk);
34
+ for (const [method, path] of [['POST', '/communications/x/send'], ['POST', '/work-items/w/resolve'], ['POST', '/books/b/runs'], ['POST', '/books/b/uploads'],
35
+ ['POST', '/campaigns/c/test-tick'], ['POST', '/campaigns/c/candidates/evaluate'], ['POST', '/enrollments/e/close'], ['POST', '/activity'],
36
+ ['DELETE', '/campaigns/c'], ['PUT', '/campaigns/c'], ['POST', '/campaigns/c/../../communications/x/send']]) {
37
+ await expect(write(method, path, {})).rejects.toThrow(/not allowed from the CLI/);
38
+ }
41
39
  expect(calls).toHaveLength(0);
42
40
  });
41
+
42
+ it('a touchpoint is added, or merged into the one with its key; a template given without a version is pinned to its latest', async () => {
43
+ const draft = { audience: { field: 'status', op: 'eq', value: 'active' }, touchpoints: [{ key: 't1', channel: 'email', terminal_action: 'draft', when: { field: 'derived.days_out', op: 'lte', value: 60 } }], escalation_policy: { then: 'create_work_item' }, channel_policy: {} };
44
+ const { calls } = recordFetch([
45
+ on('GET', '/campaigns/c1', { json: { id: 'c1', name: 'Renewals', status: 'draft', draft } }),
46
+ on('GET', '/templates/tpl1/versions', { json: { total: 2, items: [{ version_n: 2, subject: 'S2', body: 'B2' }, { version_n: 1 }] } }),
47
+ on('PUT', '/campaigns/c1/draft', (c) => ({ json: c.body })),
48
+ ]);
49
+ const sdk = new CampaignsSDK(new IndemnClient());
50
+ await sdk.setTouchpoint('c1', { key: 't2', channel: 'email', when: { field: 'derived.days_out', op: 'lte', value: 30 }, template_id: 'tpl1' });
51
+ await sdk.setTouchpoint('c1', { key: 't1', when: { field: 'derived.days_out', op: 'lte', value: 45 } });
52
+ const puts = calls.filter((c) => c.method === 'PUT').map((c) => c.body as typeof draft);
53
+ expect(puts[0].touchpoints).toEqual([draft.touchpoints[0], { key: 't2', channel: 'email', terminal_action: 'draft', when: { field: 'derived.days_out', op: 'lte', value: 30 }, template_id: 'tpl1', template_version_n: 2 }]);
54
+ expect(puts[0].audience).toEqual(draft.audience); // the rest of the draft is kept
55
+ expect(puts[1].touchpoints).toEqual([{ ...draft.touchpoints[0], when: { field: 'derived.days_out', op: 'lte', value: 45 } }]);
56
+ });
57
+
58
+ it('a draft change keeps its touchpoints; a template change starts from its latest version', async () => {
59
+ const draft = { audience: null, touchpoints: [{ key: 't1', channel: 'email' }], escalation_policy: {}, channel_policy: {} };
60
+ const { calls } = recordFetch([
61
+ on('GET', '/campaigns/c1', { json: { id: 'c1', draft } }),
62
+ on('PUT', '/campaigns/c1/draft', (c) => ({ json: c.body })),
63
+ on('GET', '/templates/tpl1/versions', { json: { items: [{ version_n: 3, subject: 'Hi {{ contact.first_name }}', body: 'Old body', html: '', uses_variables: [{ key: 'contact.first_name', required: true, example: 'Ana' }], selection_tags: {}, skills: {}, batch_row: '', published_by: 'u0' }] } }),
64
+ on('POST', '/templates/tpl1/versions', (c) => ({ json: { version_n: 4, ...(c.body as object) } })),
65
+ ]);
66
+ const sdk = new CampaignsSDK(new IndemnClient());
67
+ await sdk.updateDraft('c1', { audience: { field: 'status', op: 'eq', value: 'active' } });
68
+ await sdk.updateTemplate('tpl1', { body: 'New body {{ record.policy.number }}', uses_variables: ['contact.first_name', 'record.policy.number'], change_summary: 'shorter' });
69
+ expect(calls.find((c) => c.method === 'PUT')!.body).toEqual({ ...draft, audience: { field: 'status', op: 'eq', value: 'active' } });
70
+ // Variables given as keys: a key already declared keeps its entry, a new one is declared as {key}.
71
+ expect(calls.find((c) => c.method === 'POST')!.body).toEqual({ subject: 'Hi {{ contact.first_name }}', body: 'New body {{ record.policy.number }}', html: '', uses_variables: [{ key: 'contact.first_name', required: true, example: 'Ana' }, { key: 'record.policy.number' }], selection_tags: {}, skills: {}, batch_row: '', change_summary: 'shorter' });
72
+ });
73
+
74
+ it('writes each configuration call with its method, path and body', async () => {
75
+ const { calls } = recordFetch([
76
+ on('GET', '/template-skills/s1', { json: { id: 's1', name: 'Tone', instructions: 'Be brief.' } }),
77
+ on('GET', '/books/b1', { json: { id: 'b1', name: 'Book' } }),
78
+ (c) => (c.method === 'GET' ? undefined : { json: { id: 'x' } }),
79
+ ]);
80
+ const sdk = new CampaignsSDK(new IndemnClient());
81
+ await sdk.createCampaign({ solution_id: 's', book_id: 'b', name: 'N', campaign_type: 'renewal' });
82
+ await sdk.publish('c1', 'first');
83
+ await sdk.setStatus('c1', 'paused');
84
+ await sdk.createTemplate({ solution_id: 's', name: 'T', channel: 'email', subject: 'S', body: 'B {{ record.account.name }}', uses_variables: ['record.account.name'] });
85
+ await sdk.createTemplateSkill({ solution_id: 's', key: 'tone', name: 'Tone', instructions: 'Be brief.' });
86
+ await sdk.updateTemplateSkill('s1', { instructions: 'Be warm.' });
87
+ await sdk.createBook({ solution_id: 's', name: 'Book' });
88
+ await sdk.updateBook('b1', { source: { kind: 'upload' }, identity_keys: ['policy.number'], display: { primary: ['policy.number'], secondary: ['insured.name'] } });
89
+ const writes = calls.filter((c) => c.method !== 'GET');
90
+ expect(seen(writes)).toEqual([
91
+ 'POST /campaigns', 'POST /campaigns/c1/publish', 'POST /campaigns/c1/status', 'POST /templates', 'POST /template-skills',
92
+ 'PUT /template-skills/s1', 'POST /books', 'PATCH /books/b1/source', 'PUT /books/b1/identity-keys', 'PUT /books/b1/display',
93
+ ]);
94
+ expect(writes.map((c) => c.body)).toEqual([
95
+ { solution_id: 's', book_id: 'b', name: 'N', campaign_type: 'renewal' }, { change_summary: 'first' }, { status: 'paused' },
96
+ { solution_id: 's', name: 'T', channel: 'email', subject: 'S', body: 'B {{ record.account.name }}', uses_variables: [{ key: 'record.account.name' }] }, { solution_id: 's', key: 'tone', name: 'Tone', instructions: 'Be brief.' },
97
+ { name: 'Tone', instructions: 'Be warm.' }, { solution_id: 's', name: 'Book' }, { source: { kind: 'upload' } }, // the service reads body.source
98
+ { identity_keys: ['policy.number'] }, { display: { primary: ['policy.number'], secondary: ['insured.name'] } },
99
+ ]);
100
+ });
43
101
  });