@startsimpli/integrations 0.2.36 → 0.2.37

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.
package/README.md CHANGED
@@ -52,6 +52,8 @@ Peer deps (both optional, only needed for the hooks/components subpaths):
52
52
 
53
53
  You can consume `@startsimpli/integrations/types` and `@startsimpli/integrations/api` from a non-React context (e.g. a Node server, an MSW handler) without pulling in React.
54
54
 
55
+ Foundry customer email (comeback sequences, product updates) lives at the `@startsimpli/integrations/comms` subpath (also `/comms/types`, `/comms/api`, `/comms/hooks`, `/comms/components`). It is not re-exported from the root, and it additionally needs the optional peer `@startsimpli/ui`.
56
+
55
57
  ## Public surface
56
58
 
57
59
  | Export | Type | Description |
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@startsimpli/integrations",
3
- "version": "0.2.36",
4
- "description": "Provider-agnostic types, API clients, hooks, and UI primitives for email + calendar integrations across StartSimpli apps.",
3
+ "version": "0.2.37",
4
+ "description": "Provider-agnostic types, API clients, hooks, and UI primitives for email + calendar integrations across StartSimpli apps, plus foundry customer email (subpath ./comms).",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
7
7
  "exports": {
@@ -11,7 +11,12 @@
11
11
  "./corpus": "./src/corpus/index.ts",
12
12
  "./corpus-llm": "./src/corpus/llm.ts",
13
13
  "./safe-fetch": "./src/safe-fetch/index.ts",
14
- "./ingest": "./src/ingest/index.ts"
14
+ "./ingest": "./src/ingest/index.ts",
15
+ "./comms": "./src/comms/index.ts",
16
+ "./comms/types": "./src/comms/types/index.ts",
17
+ "./comms/api": "./src/comms/api/index.ts",
18
+ "./comms/hooks": "./src/comms/hooks/index.ts",
19
+ "./comms/components": "./src/comms/components/index.ts"
15
20
  },
16
21
  "files": [
17
22
  "src",
@@ -29,7 +34,8 @@
29
34
  "peerDependencies": {
30
35
  "react": ">=18.0.0",
31
36
  "@tanstack/react-query": ">=5.0.0",
32
- "@startsimpli/llm": ">=0.4.0"
37
+ "@startsimpli/llm": ">=0.4.0",
38
+ "@startsimpli/ui": "^0.4.155"
33
39
  },
34
40
  "peerDependenciesMeta": {
35
41
  "react": {
@@ -40,6 +46,9 @@
40
46
  },
41
47
  "@startsimpli/llm": {
42
48
  "optional": true
49
+ },
50
+ "@startsimpli/ui": {
51
+ "optional": true
43
52
  }
44
53
  },
45
54
  "devDependencies": {
@@ -55,6 +64,7 @@
55
64
  "react-dom": "^19.0.0",
56
65
  "typescript": "^6.0.0",
57
66
  "vitest": "^3.0.0",
67
+ "@startsimpli/ui": "0.4.155",
58
68
  "@startsimpli/llm": "0.4.25"
59
69
  },
60
70
  "scripts": {
@@ -0,0 +1,327 @@
1
+ /**
2
+ * CommsApi against a mocked Django (msw). One round-trip per route in ADR
3
+ * startsim-bzcrj section 2: the method, the path under
4
+ * /api/v1/foundry/tenants/{slug}/comms/, the body, the query params, and that
5
+ * the parsed response is what the caller gets back.
6
+ *
7
+ * Pagination is pinned to `page` + `page_size`, which is what the ADR specifies.
8
+ * NOTE for the backend lanes: DRF's DEFAULT paginator here
9
+ * (core.pagination.StandardPaginatedResponse) reads `pageSize`, so the sends and
10
+ * drafts views must use core.api.pagination.FlexiblePageNumberPagination or the
11
+ * page size is silently ignored.
12
+ */
13
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
14
+ import { http, HttpResponse } from 'msw';
15
+ import { setupServer } from 'msw/node';
16
+
17
+ import { createCommsApi, setApiBase, setAuthTokenProvider } from '../api/index';
18
+ import { CommsApiError } from '../types/index';
19
+ import { BASE, P, SLUG, comeback, draft, generation, metrics, productUpdate, sendRows, settings } from './fixtures';
20
+
21
+ interface Seen {
22
+ method: string;
23
+ path: string;
24
+ search: URLSearchParams;
25
+ body: unknown;
26
+ auth: string | null;
27
+ }
28
+
29
+ let seen: Seen[] = [];
30
+ let reply: (s: Seen) => Response = () => HttpResponse.json({});
31
+
32
+ const server = setupServer(
33
+ http.all(`${BASE}/api/v1/*`, async ({ request }) => {
34
+ const url = new URL(request.url);
35
+ const text = request.method === 'GET' ? '' : await request.text();
36
+ const s: Seen = {
37
+ method: request.method,
38
+ path: url.pathname,
39
+ search: url.searchParams,
40
+ body: text ? JSON.parse(text) : undefined,
41
+ auth: request.headers.get('Authorization'),
42
+ };
43
+ seen.push(s);
44
+ return reply(s);
45
+ }),
46
+ );
47
+
48
+ beforeAll(() => {
49
+ server.listen({ onUnhandledRequest: 'error' });
50
+ setApiBase(`${BASE}/api/v1`);
51
+ });
52
+ afterEach(() => {
53
+ server.resetHandlers();
54
+ setAuthTokenProvider(null);
55
+ });
56
+ afterAll(() => server.close());
57
+ beforeEach(() => {
58
+ seen = [];
59
+ reply = () => HttpResponse.json({});
60
+ });
61
+
62
+ const api = () => createCommsApi(SLUG);
63
+ const pathOf = (url: string) => new URL(url).pathname;
64
+ const last = () => seen[seen.length - 1];
65
+
66
+ describe('settings', () => {
67
+ it('GET settings/', async () => {
68
+ reply = () => HttpResponse.json(settings());
69
+ const r = await api().getSettings();
70
+ expect(last().method).toBe('GET');
71
+ expect(last().path).toBe(pathOf(`${P}/settings/`));
72
+ expect(r.approval_mode).toBe('approve');
73
+ });
74
+
75
+ it('PATCH settings/ sends only the changed fields', async () => {
76
+ reply = () => HttpResponse.json(settings({ approval_mode: 'auto' }));
77
+ const r = await api().updateSettings({ approval_mode: 'auto' });
78
+ expect(last().method).toBe('PATCH');
79
+ expect(last().path).toBe(pathOf(`${P}/settings/`));
80
+ expect(last().body).toEqual({ approval_mode: 'auto' });
81
+ expect(r.approval_mode).toBe('auto');
82
+ });
83
+
84
+ it('POST settings/domain/ with {domain}', async () => {
85
+ reply = () => HttpResponse.json(settings({ sending_domain: 'crochets.site', domain_status: 'pending' }));
86
+ const r = await api().connectDomain('crochets.site');
87
+ expect(last().method).toBe('POST');
88
+ expect(last().path).toBe(pathOf(`${P}/settings/domain/`));
89
+ expect(last().body).toEqual({ domain: 'crochets.site' });
90
+ expect(r.domain_status).toBe('pending');
91
+ });
92
+
93
+ it('POST settings/domain/check/', async () => {
94
+ reply = () => HttpResponse.json(settings({ domain_status: 'verified' }));
95
+ const r = await api().checkDomain();
96
+ expect(last().method).toBe('POST');
97
+ expect(last().path).toBe(pathOf(`${P}/settings/domain/check/`));
98
+ expect(r.domain_status).toBe('verified');
99
+ });
100
+ });
101
+
102
+ describe('programs', () => {
103
+ it('GET programs/', async () => {
104
+ reply = () => HttpResponse.json([comeback(), productUpdate()]);
105
+ const r = await api().listPrograms();
106
+ expect(last().path).toBe(pathOf(`${P}/programs/`));
107
+ expect(r.map((p) => p.key)).toEqual(['comeback', 'product_update']);
108
+ });
109
+
110
+ it('GET programs/{key}/', async () => {
111
+ reply = () => HttpResponse.json(comeback());
112
+ const r = await api().getProgram('comeback');
113
+ expect(last().path).toBe(pathOf(`${P}/programs/comeback/`));
114
+ expect(r.kind).toBe('sequence');
115
+ });
116
+
117
+ it('PATCH programs/{key}/ with {enabled:false}', async () => {
118
+ reply = () => HttpResponse.json(comeback({ enabled: false }));
119
+ const r = await api().updateProgram('comeback', { enabled: false });
120
+ expect(last().method).toBe('PATCH');
121
+ expect(last().path).toBe(pathOf(`${P}/programs/comeback/`));
122
+ expect(last().body).toEqual({ enabled: false });
123
+ expect(r.enabled).toBe(false);
124
+ });
125
+
126
+ // Shipped contract (AI lane): generation runs on a worker. POST answers 202
127
+ // with a status, not the program; GET on the same route is the poll.
128
+ it('POST programs/{key}/steps/{i}/generate/ with instructions -> 202 status', async () => {
129
+ reply = () => HttpResponse.json(generation(), { status: 202 });
130
+ const r = await api().generateStep('comeback', 2, { instructions: 'mention the new stitch counter' });
131
+ expect(last().method).toBe('POST');
132
+ expect(last().path).toBe(pathOf(`${P}/programs/comeback/steps/2/generate/`));
133
+ expect(last().body).toEqual({ instructions: 'mention the new stitch counter' });
134
+ expect(r.status).toBe('pending');
135
+ });
136
+
137
+ it('GET programs/{key}/steps/{i}/generate/ polls the generation status', async () => {
138
+ reply = () => HttpResponse.json(generation({ status: 'failed', error: 'model timed out' }));
139
+ const r = await api().getStepGeneration('comeback', 2);
140
+ expect(last().method).toBe('GET');
141
+ expect(last().path).toBe(pathOf(`${P}/programs/comeback/steps/2/generate/`));
142
+ expect(r).toMatchObject({ status: 'failed', error: 'model timed out' });
143
+ });
144
+
145
+ it('PATCH enabled:false passes the cancelled count through', async () => {
146
+ reply = () => HttpResponse.json({ ...comeback({ enabled: false }), cancelled: 12 });
147
+ const r = await api().updateProgram('comeback', { enabled: false });
148
+ expect(r.cancelled).toBe(12);
149
+ });
150
+
151
+ it('POST programs/{key}/steps/{i}/approve/', async () => {
152
+ reply = () => HttpResponse.json(comeback());
153
+ await api().approveStep('comeback', 1);
154
+ expect(last().method).toBe('POST');
155
+ expect(last().path).toBe(pathOf(`${P}/programs/comeback/steps/1/approve/`));
156
+ });
157
+
158
+ it('GET programs/{key}/steps/{i}/preview/', async () => {
159
+ reply = () => HttpResponse.json({ subject: 'S', preheader: 'PH', html: '<p>Hi Quinn</p>', text: 'Hi Quinn' });
160
+ const r = await api().previewStep('comeback', 0);
161
+ expect(last().method).toBe('GET');
162
+ expect(last().path).toBe(pathOf(`${P}/programs/comeback/steps/0/preview/`));
163
+ expect(r.html).toBe('<p>Hi Quinn</p>');
164
+ });
165
+ });
166
+
167
+ describe('test send', () => {
168
+ it('POST test-send/ for a sequence step', async () => {
169
+ reply = () => HttpResponse.json({ sent: true });
170
+ const r = await api().sendTest({ program_key: 'comeback', step_index: 1 });
171
+ expect(last().method).toBe('POST');
172
+ expect(last().path).toBe(pathOf(`${P}/test-send/`));
173
+ expect(last().body).toEqual({ program_key: 'comeback', step_index: 1 });
174
+ expect(r.sent).toBe(true);
175
+ });
176
+
177
+ it('POST test-send/ for a draft', async () => {
178
+ reply = () => HttpResponse.json({ sent: false, reason: 'not_allowlisted' });
179
+ const r = await api().sendTest({ draft_id: 'd-1' });
180
+ expect(last().body).toEqual({ draft_id: 'd-1' });
181
+ expect(r).toEqual({ sent: false, reason: 'not_allowlisted' });
182
+ });
183
+ });
184
+
185
+ describe('product updates', () => {
186
+ it('GET changes/?since=&until=', async () => {
187
+ reply = () => HttpResponse.json({ items: [], window: ['a', 'b'] });
188
+ await api().listChanges({ since: '2026-10-01T00:00:00Z', until: '2026-10-15T00:00:00Z' });
189
+ expect(last().path).toBe(pathOf(`${P}/changes/`));
190
+ expect(last().search.get('since')).toBe('2026-10-01T00:00:00Z');
191
+ expect(last().search.get('until')).toBe('2026-10-15T00:00:00Z');
192
+ });
193
+
194
+ it('GET drafts/ with status, page and page_size', async () => {
195
+ reply = () => HttpResponse.json({ count: 1, next: null, previous: null, results: [draft()] });
196
+ const r = await api().listDrafts({ status: 'pending', page: 2, page_size: 10 });
197
+ expect(last().path).toBe(pathOf(`${P}/drafts/`));
198
+ expect(last().search.get('status')).toBe('pending');
199
+ expect(last().search.get('page')).toBe('2');
200
+ expect(last().search.get('page_size')).toBe('10');
201
+ expect(r.results[0].id).toBe('d-1');
202
+ });
203
+
204
+ it('GET drafts/{id}/', async () => {
205
+ reply = () => HttpResponse.json(draft());
206
+ await api().getDraft('d-1');
207
+ expect(last().path).toBe(pathOf(`${P}/drafts/d-1/`));
208
+ });
209
+
210
+ it('PATCH drafts/{id}/ with the edited fields', async () => {
211
+ reply = () => HttpResponse.json(draft({ subject: 'Edited' }));
212
+ const r = await api().updateDraft('d-1', { subject: 'Edited' });
213
+ expect(last().method).toBe('PATCH');
214
+ expect(last().path).toBe(pathOf(`${P}/drafts/d-1/`));
215
+ expect(last().body).toEqual({ subject: 'Edited' });
216
+ expect(r.subject).toBe('Edited');
217
+ });
218
+
219
+ it('POST drafts/{id}/approve/ and drafts/{id}/discard/', async () => {
220
+ reply = (s) => HttpResponse.json(draft({ approval: s.path.endsWith('approve/') ? 'approved' : 'discarded' }));
221
+ const a = await api().approveDraft('d-1');
222
+ expect(last().method).toBe('POST');
223
+ expect(last().path).toBe(pathOf(`${P}/drafts/d-1/approve/`));
224
+ expect(a.approval).toBe('approved');
225
+ const d = await api().discardDraft('d-1');
226
+ expect(last().path).toBe(pathOf(`${P}/drafts/d-1/discard/`));
227
+ expect(d.approval).toBe('discarded');
228
+ });
229
+
230
+ it('GET drafts/{id}/preview/ is the server-rendered draft', async () => {
231
+ reply = () => HttpResponse.json({ subject: 'S', preheader: 'PH', html: '<p>Hi Quinn</p>', text: 'Hi' });
232
+ const r = await api().previewDraft('d-1');
233
+ expect(last().method).toBe('GET');
234
+ expect(last().path).toBe(pathOf(`${P}/drafts/d-1/preview/`));
235
+ expect(r.html).toBe('<p>Hi Quinn</p>');
236
+ });
237
+
238
+ // Shipped contract (updates lane): 202 with a placeholder draft still generating.
239
+ it('POST programs/product_update/send-now/ with notes', async () => {
240
+ reply = () => HttpResponse.json({ draft: draft({ generating: true, subject: '' }), reason: null }, { status: 202 });
241
+ const r = await api().sendUpdateNow({ notes: 'We launched the stitch counter' });
242
+ expect(last().method).toBe('POST');
243
+ expect(last().path).toBe(pathOf(`${P}/programs/product_update/send-now/`));
244
+ expect(last().body).toEqual({ notes: 'We launched the stitch counter' });
245
+ expect(r.draft?.id).toBe('d-1');
246
+ expect(r.draft?.generating).toBe(true);
247
+ });
248
+
249
+ it('send-now passes "nothing customer-facing" through as data, not an error', async () => {
250
+ reply = () => HttpResponse.json({ draft: null, reason: 'nothing_customer_facing' });
251
+ const r = await api().sendUpdateNow();
252
+ expect(r).toEqual({ draft: null, reason: 'nothing_customer_facing' });
253
+ });
254
+ });
255
+
256
+ describe('results', () => {
257
+ it('GET sends/ sends page, page_size and filters to the SERVER', async () => {
258
+ reply = () => HttpResponse.json({ count: 120, next: `${P}/sends/?page=3`, previous: `${P}/sends/?page=1`, results: sendRows(25, 25) });
259
+ const r = await api().listSends({ program: 'comeback', status: 'bounced', page: 2, page_size: 25 });
260
+ expect(last().path).toBe(pathOf(`${P}/sends/`));
261
+ expect(last().search.get('page')).toBe('2');
262
+ expect(last().search.get('page_size')).toBe('25');
263
+ expect(last().search.get('program')).toBe('comeback');
264
+ expect(last().search.get('status')).toBe('bounced');
265
+ expect(r.count).toBe(120);
266
+ expect(r.results).toHaveLength(25);
267
+ });
268
+
269
+ it('GET sends/?campaign= narrows to one product-update campaign', async () => {
270
+ reply = () => HttpResponse.json({ count: 0, next: null, previous: null, results: [] });
271
+ await api().listSends({ campaign: 'd-1', page: 1, page_size: 25 });
272
+ expect(last().search.get('campaign')).toBe('d-1');
273
+ });
274
+
275
+ it('GET metrics/?program=&from=&to=', async () => {
276
+ reply = () => HttpResponse.json(metrics());
277
+ const r = await api().getMetrics({ program: 'comeback', from: '2026-10-01', to: '2026-10-15' });
278
+ expect(last().path).toBe(pathOf(`${P}/metrics/`));
279
+ expect(last().search.get('program')).toBe('comeback');
280
+ expect(last().search.get('from')).toBe('2026-10-01');
281
+ expect(last().search.get('to')).toBe('2026-10-15');
282
+ expect(r.came_back).toBe(11);
283
+ });
284
+ });
285
+
286
+ describe('transport', () => {
287
+ it('binds to the slug it was created with, URL-encoded', async () => {
288
+ reply = () => HttpResponse.json(settings());
289
+ await createCommsApi('a b').getSettings();
290
+ expect(last().path).toBe('/api/v1/foundry/tenants/a%20b/comms/settings/');
291
+ });
292
+
293
+ it('attaches the bearer from the wired token provider', async () => {
294
+ setAuthTokenProvider(() => 'tok-123');
295
+ reply = () => HttpResponse.json(settings());
296
+ await api().getSettings();
297
+ expect(last().auth).toBe('Bearer tok-123');
298
+ });
299
+
300
+ it('a 403 (not a foundry admin) is a CommsApiError carrying the status', async () => {
301
+ reply = () => HttpResponse.json({ detail: 'You do not have permission to perform this action.' }, { status: 403 });
302
+ const err = await api().getSettings().catch((e: unknown) => e);
303
+ expect(err).toBeInstanceOf(CommsApiError);
304
+ expect((err as CommsApiError).status).toBe(403);
305
+ expect((err as CommsApiError).message).toMatch(/permission/);
306
+ });
307
+
308
+ it('a DRF field error reads as a sentence, not "HTTP 400"', async () => {
309
+ reply = () => HttpResponse.json({ config: { 'steps[1].offset_days': 'whole days >= 0, not before the previous step' } }, { status: 400 });
310
+ const err = await api().updateProgram('comeback', { enabled: true }).catch((e: unknown) => e);
311
+ expect((err as CommsApiError).message).toBe('steps[1].offset_days: whole days >= 0, not before the previous step');
312
+ });
313
+
314
+ it('a refused test send (503) carries its reason', async () => {
315
+ reply = () => HttpResponse.json({ sent: false, reason: 'marketing email is not configured' }, { status: 503 });
316
+ const err = await api().sendTest({ draft_id: 'd-1' }).catch((e: unknown) => e);
317
+ expect((err as CommsApiError).status).toBe(503);
318
+ expect((err as CommsApiError).message).toMatch(/not configured/);
319
+ });
320
+
321
+ it("another foundry's slug is a 404 CommsApiError, not an empty settings object", async () => {
322
+ reply = () => HttpResponse.json({ detail: 'Not found.' }, { status: 404 });
323
+ const err = await api().getSettings().catch((e: unknown) => e);
324
+ expect(err).toBeInstanceOf(CommsApiError);
325
+ expect((err as CommsApiError).status).toBe(404);
326
+ });
327
+ });