@startsimpli/integrations 0.2.46 → 0.2.48

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/integrations",
3
- "version": "0.2.46",
3
+ "version": "0.2.48",
4
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",
@@ -41,7 +41,8 @@ const server = setupServer(
41
41
  if (hit.path === '/programs/') return HttpResponse.json([comeback(), productUpdate()]);
42
42
  if (hit.path === '/drafts/' && hit.method === 'POST') return createReply();
43
43
  if (hit.path === '/drafts/') {
44
- const results = drafts.filter((d) => d.approval === 'pending');
44
+ const wanted = url.searchParams.get('status') ?? 'pending';
45
+ const results = drafts.filter((d) => d.approval === wanted);
45
46
  return HttpResponse.json({ count: results.length, next: null, previous: null, results });
46
47
  }
47
48
  const m = hit.path.match(/^\/drafts\/([^/]+)\/$/);
@@ -235,6 +235,23 @@ describe('product updates', () => {
235
235
  expect(r.html).toBe('<p>Hi Quinn</p>');
236
236
  });
237
237
 
238
+ it('per-send audience: GET/PUT drafts/{id}/audience/, POST .../preview/, GET audience/segments/', async () => {
239
+ const result = { audience: { base: 'all' }, is_default: true, preview: { count: 3, eligible: 3, excluded: {}, excluded_total: 0, sample: [], excluded_sample: [] } };
240
+ reply = () => HttpResponse.json(result);
241
+ await api().getDraftAudience('d-1');
242
+ expect([last().method, last().path]).toEqual(['GET', pathOf(`${P}/drafts/d-1/audience/`)]);
243
+ await api().saveDraftAudience('d-1', { base: 'inactive', days: 30 });
244
+ expect([last().method, last().path]).toEqual(['PUT', pathOf(`${P}/drafts/d-1/audience/`)]);
245
+ expect(last().body).toEqual({ audience: { base: 'inactive', days: 30 } });
246
+ await api().previewDraftAudience('d-1', { segment: { key: 'paying' } });
247
+ expect([last().method, last().path]).toEqual(['POST', pathOf(`${P}/drafts/d-1/audience/preview/`)]);
248
+ expect(last().body).toEqual({ audience: { segment: { key: 'paying' } } });
249
+ reply = () => HttpResponse.json({ available: false, segments: [] });
250
+ const segs = await api().listAudienceSegments();
251
+ expect(last().path).toBe(pathOf(`${P}/audience/segments/`));
252
+ expect(segs.available).toBe(false);
253
+ });
254
+
238
255
  // Shipped contract (updates lane): 202 with a placeholder draft still generating.
239
256
  it('POST programs/product_update/send-now/ with notes', async () => {
240
257
  reply = () => HttpResponse.json({ draft: draft({ generating: true, subject: '' }), reason: null }, { status: 202 });
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Per-send audience on a pending draft (bd startsim-xkcnk): an "Audience"
3
+ * disclosure under each draft that shows who will receive it, lets the admin
4
+ * narrow it (activity, signup dates, a segment the foundry's backend defines,
5
+ * explicit exclusions), previews the count with an "excluded and why"
6
+ * breakdown, and saves it on the draft. Nothing is requested until it is opened.
7
+ */
8
+ import * as React from 'react';
9
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
10
+ import { render, screen, waitFor, within } from '@testing-library/react';
11
+ import userEvent from '@testing-library/user-event';
12
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
13
+ import { http, HttpResponse } from 'msw';
14
+ import { setupServer } from 'msw/node';
15
+
16
+ import { DraftReview } from '../components/DraftReview';
17
+ import { createCommsApi, setApiBase } from '../api/index';
18
+ import type { AudiencePreview, AudienceSpec, DraftAudience } from '../types/index';
19
+ import { BASE, P, SLUG, draft } from './fixtures';
20
+
21
+ interface Hit { method: string; path: string; body: any }
22
+ let hits: Hit[] = [];
23
+ let audience: DraftAudience;
24
+ let segmentsReply: () => Response;
25
+ let previewReply: (spec: AudienceSpec) => Response;
26
+
27
+ const rel = (p: string) => p.replace(new URL(P).pathname, '');
28
+ const preview = (over: Partial<AudiencePreview> = {}): AudiencePreview => ({
29
+ count: 42, eligible: 50, excluded: { opted_out: 5, suppressed: 3 }, excluded_total: 8,
30
+ sample: ['a@x.com', 'b@x.com'], excluded_sample: [{ email: 'gone@x.com', reason: 'opted_out' }], ...over,
31
+ });
32
+
33
+ const server = setupServer(
34
+ http.all(`${P}/*`, async ({ request }) => {
35
+ const url = new URL(request.url);
36
+ const text = request.method === 'GET' ? '' : await request.text();
37
+ const hit: Hit = { method: request.method, path: rel(url.pathname), body: text ? JSON.parse(text) : undefined };
38
+ hits.push(hit);
39
+ if (hit.path === '/drafts/') {
40
+ return HttpResponse.json({ count: 1, next: null, previous: null, results: [draft()] });
41
+ }
42
+ if (hit.path === '/audience/segments/') return segmentsReply();
43
+ if (hit.path === '/drafts/d-1/audience/' && hit.method === 'GET') return HttpResponse.json(audience);
44
+ if (hit.path === '/drafts/d-1/audience/' && hit.method === 'PUT') {
45
+ return HttpResponse.json({ audience: hit.body.audience, is_default: false, preview: preview() });
46
+ }
47
+ if (hit.path === '/drafts/d-1/audience/preview/') return previewReply(hit.body.audience);
48
+ return HttpResponse.json({});
49
+ }),
50
+ );
51
+
52
+ beforeAll(() => {
53
+ server.listen({ onUnhandledRequest: 'error' });
54
+ setApiBase(`${BASE}/api/v1`);
55
+ });
56
+ afterAll(() => server.close());
57
+ beforeEach(() => {
58
+ hits = [];
59
+ audience = { audience: { base: 'all' }, is_default: true, preview: preview() };
60
+ segmentsReply = () => HttpResponse.json({
61
+ available: true,
62
+ segments: [{ key: 'never_designed', label: 'Signed up, never made a design', description: '' },
63
+ { key: 'paying', label: 'Paying users', description: '' }],
64
+ });
65
+ previewReply = (spec) => HttpResponse.json({ audience: spec, is_default: false, preview: preview({ count: 7, excluded: {}, excluded_total: 0, excluded_sample: [] }) });
66
+ });
67
+ afterEach(() => server.resetHandlers());
68
+
69
+ async function openAudience() {
70
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
71
+ const user = userEvent.setup();
72
+ render(
73
+ <QueryClientProvider client={qc}>
74
+ <DraftReview api={createCommsApi(SLUG)} pollIntervalMs={20} />
75
+ </QueryClientProvider>,
76
+ );
77
+ const trigger = await screen.findByRole('button', { name: 'Audience' });
78
+ return { user, trigger };
79
+ }
80
+
81
+ const previews = () => hits.filter((h) => h.path === '/drafts/d-1/audience/preview/');
82
+
83
+ describe('Audience panel', () => {
84
+ it('asks for nothing until it is opened, then shows who receives it and why others are excluded', async () => {
85
+ const { user, trigger } = await openAudience();
86
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
87
+ expect(hits.some((h) => h.path.includes('audience'))).toBe(false);
88
+ await user.click(trigger);
89
+ const region = await screen.findByRole('region', { name: 'Audience' });
90
+ expect(await within(region).findByText(/42 people will receive this/)).toBeInTheDocument();
91
+ expect(within(region).getByText(/8 excluded/)).toBeInTheDocument();
92
+ expect(within(region).getByText(/5 unsubscribed/)).toBeInTheDocument();
93
+ expect(within(region).getByText(/3 suppressed/)).toBeInTheDocument();
94
+ expect(within(region).getByText('a@x.com')).toBeInTheDocument();
95
+ expect(within(region).getByText(/Using the program default/)).toBeInTheDocument();
96
+ });
97
+
98
+ it('previews a narrower activity audience without saving', async () => {
99
+ const { user, trigger } = await openAudience();
100
+ await user.click(trigger);
101
+ const region = await screen.findByRole('region', { name: 'Audience' });
102
+ await user.selectOptions(await within(region).findByLabelText('Who'), 'inactive');
103
+ await waitFor(() => expect(previews().at(-1)?.body.audience).toMatchObject({ base: 'inactive', days: 14 }));
104
+ expect(await within(region).findByText(/7 people will receive this/)).toBeInTheDocument();
105
+ expect(hits.some((h) => h.method === 'PUT')).toBe(false);
106
+ });
107
+
108
+ it('lists the foundry\'s segments and previews the chosen one', async () => {
109
+ const { user, trigger } = await openAudience();
110
+ await user.click(trigger);
111
+ const region = await screen.findByRole('region', { name: 'Audience' });
112
+ await user.selectOptions(await within(region).findByLabelText('Only people in segment'), 'never_designed');
113
+ await waitFor(() => expect(previews().at(-1)?.body.audience.segment).toEqual({ key: 'never_designed' }));
114
+ });
115
+
116
+ it('says segments are unavailable when the foundry has no backend', async () => {
117
+ segmentsReply = () => HttpResponse.json({ available: false, segments: [] });
118
+ const { user, trigger } = await openAudience();
119
+ await user.click(trigger);
120
+ const region = await screen.findByRole('region', { name: 'Audience' });
121
+ expect(await within(region).findByText(/Segments need the foundry's own backend/)).toBeInTheDocument();
122
+ expect(within(region).queryByLabelText('Only people in segment')).toBeNull();
123
+ });
124
+
125
+ it('takes signup dates and an exclusion list, one address per line', async () => {
126
+ const { user, trigger } = await openAudience();
127
+ await user.click(trigger);
128
+ const region = await screen.findByRole('region', { name: 'Audience' });
129
+ await user.type(await within(region).findByLabelText('Signed up after'), '2026-10-01');
130
+ await user.type(within(region).getByLabelText('Never send to'), 'A@x.com{enter}b@x.com');
131
+ await waitFor(() => expect(previews().at(-1)?.body.audience).toMatchObject({
132
+ joined_after: '2026-10-01', exclude: { emails: ['a@x.com', 'b@x.com'] },
133
+ }));
134
+ });
135
+
136
+ it('saves the audience on the draft', async () => {
137
+ const { user, trigger } = await openAudience();
138
+ await user.click(trigger);
139
+ const region = await screen.findByRole('region', { name: 'Audience' });
140
+ await user.selectOptions(await within(region).findByLabelText('Who'), 'active');
141
+ await user.click(within(region).getByRole('button', { name: 'Save audience' }));
142
+ await waitFor(() => expect(hits.find((h) => h.method === 'PUT')?.body).toEqual({
143
+ audience: { base: 'active', days: 14 },
144
+ }));
145
+ });
146
+
147
+ it('shows the server\'s reason when the audience cannot be resolved', async () => {
148
+ previewReply = () => HttpResponse.json({ detail: 'the foundry\'s backend refused the segment request (500)' }, { status: 400 });
149
+ const { user, trigger } = await openAudience();
150
+ await user.click(trigger);
151
+ const region = await screen.findByRole('region', { name: 'Audience' });
152
+ await user.selectOptions(await within(region).findByLabelText('Who'), 'inactive');
153
+ expect(await within(region).findByRole('alert')).toHaveTextContent(/refused the segment request/);
154
+ });
155
+ });
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Scheduled and cancellable sends (bd startsim-yzivy): schedule a pending draft,
3
+ * see it listed with its send time, reschedule / send now / cancel it, and see
4
+ * scheduled and cancelled drafts in the send log. A mocked Django (msw) behind
5
+ * the REAL createCommsApi client, so each test is click -> request -> render.
6
+ */
7
+ import * as React from 'react';
8
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
9
+ import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
10
+ import userEvent from '@testing-library/user-event';
11
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
12
+ import { http, HttpResponse } from 'msw';
13
+ import { setupServer } from 'msw/node';
14
+
15
+ import { DraftReview } from '../components/DraftReview';
16
+ import { SendLog } from '../components/SendLog';
17
+ import { createCommsApi, setApiBase } from '../api/index';
18
+ import type { Draft, ScheduledSend } from '../types/index';
19
+ import { BASE, P, SLUG, draft } from './fixtures';
20
+
21
+ interface Hit { method: string; path: string; body: any }
22
+ let hits: Hit[] = [];
23
+ let drafts: Draft[] = [];
24
+ let scheduledReport: ScheduledSend[] = [];
25
+ let conflict = false;
26
+ let liveCount = 2;
27
+
28
+ const rel = (p: string) => p.replace(new URL(P).pathname, '');
29
+
30
+ const server = setupServer(
31
+ http.all(`${P}/*`, async ({ request }) => {
32
+ const url = new URL(request.url);
33
+ const text = request.method === 'GET' ? '' : await request.text();
34
+ const hit: Hit = { method: request.method, path: rel(url.pathname), body: text ? JSON.parse(text) : undefined };
35
+ hits.push(hit);
36
+ const { method, path, body } = hit;
37
+ let m: RegExpMatchArray | null;
38
+ if (path === '/drafts/' && method === 'GET') {
39
+ const wanted = url.searchParams.get('status');
40
+ const results = drafts.filter((d) => !wanted || d.approval === wanted);
41
+ return HttpResponse.json({ count: results.length, next: null, previous: null, results });
42
+ }
43
+ if ((m = path.match(/^\/drafts\/([\w-]+)\/(approve|reschedule|cancel)\/$/)) && method === 'POST') {
44
+ const d = drafts.find((x) => x.id === m![1])!;
45
+ if (conflict) return HttpResponse.json({ detail: 'the send has already started and cannot be cancelled' }, { status: 409 });
46
+ if (m[2] === 'approve') {
47
+ if (body?.send_at && body.send_at < '2026-01-01') Object.assign(d, { approval: 'approved', state: 'sending', sent_immediately: true, requested_send_at: '2020-01-01T00:00:00+00:00' });
48
+ else if (body?.send_at) Object.assign(d, { approval: 'scheduled', send_at: '2026-11-03T15:00:00+00:00', send_timezone: body.timezone ?? null });
49
+ else d.approval = 'approved';
50
+ } else if (m[2] === 'reschedule') {
51
+ Object.assign(d, { send_at: '2026-11-05T15:00:00+00:00', send_timezone: body.timezone ?? null });
52
+ } else {
53
+ Object.assign(d, { approval: 'pending', send_at: null, send_timezone: null, cancelled_at: '2026-11-01T09:00:00+00:00' });
54
+ }
55
+ return HttpResponse.json(d);
56
+ }
57
+ if ((m = path.match(/^\/drafts\/([\w-]+)\/retry\/$/)) && method === 'POST') {
58
+ const d = drafts.find((x) => x.id === m![1])!;
59
+ Object.assign(d, { approval: 'approved', state: 'sending', failure: null, next_actions: ['wait'] });
60
+ return HttpResponse.json(d);
61
+ }
62
+ if ((m = path.match(/^\/drafts\/([\w-]+)\/audience\/$/)) && method === 'GET') {
63
+ return HttpResponse.json({ audience: { base: 'all' }, is_default: true, preview: { count: liveCount, eligible: liveCount, excluded: {}, excluded_total: 0, sample: [], excluded_sample: [] } });
64
+ }
65
+ if (path === '/sends/' && method === 'GET') {
66
+ return HttpResponse.json({ count: 0, next: null, previous: null, results: [], scheduled: scheduledReport });
67
+ }
68
+ return HttpResponse.json({ detail: `unhandled ${method} ${path}` }, { status: 599 });
69
+ }),
70
+ );
71
+
72
+ beforeAll(() => {
73
+ server.listen({ onUnhandledRequest: 'error' });
74
+ setApiBase(`${BASE}/api/v1`);
75
+ });
76
+ afterEach(() => server.resetHandlers());
77
+ afterAll(() => server.close());
78
+ beforeEach(() => {
79
+ hits = [];
80
+ drafts = [draft()];
81
+ scheduledReport = [];
82
+ conflict = false;
83
+ liveCount = 2;
84
+ });
85
+
86
+ function renderWith(node: (api: ReturnType<typeof createCommsApi>) => React.ReactElement) {
87
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
88
+ return render(<QueryClientProvider client={qc}>{node(createCommsApi(SLUG))}</QueryClientProvider>);
89
+ }
90
+
91
+ const writes = () => hits.filter((h) => h.method !== 'GET');
92
+
93
+ async function schedule(when = '2026-11-03T10:00', zone = 'Asia/Tokyo') {
94
+ await userEvent.click(await screen.findByRole('button', { name: /schedule/i }));
95
+ fireEvent.change(screen.getByLabelText('Send at'), { target: { value: when } });
96
+ await userEvent.selectOptions(screen.getByLabelText('Time zone'), zone);
97
+ await userEvent.click(screen.getByRole('button', { name: /approve and schedule/i }));
98
+ }
99
+
100
+ describe('scheduling a pending draft', () => {
101
+ it('Approve and send still sends now, with no schedule on the wire', async () => {
102
+ renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
103
+ await userEvent.click(await screen.findByRole('button', { name: /approve and send/i }));
104
+ await waitFor(() => expect(writes()[0]).toMatchObject({ method: 'POST', path: '/drafts/d-1/approve/' }));
105
+ expect(writes()[0].body).toBeUndefined();
106
+ });
107
+
108
+ it('posts the local time with its zone, then lists the draft as scheduled', async () => {
109
+ renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
110
+ await schedule();
111
+ await waitFor(() =>
112
+ expect(writes()[0]).toMatchObject({
113
+ method: 'POST', path: '/drafts/d-1/approve/', body: { send_at: '2026-11-03T10:00', timezone: 'Asia/Tokyo' },
114
+ }),
115
+ );
116
+ const heading = await screen.findByRole('heading', { name: /scheduled to send/i });
117
+ const list = heading.parentElement as HTMLElement;
118
+ expect(await within(list).findByText('New: stitch counter')).toBeInTheDocument();
119
+ expect(within(list).getByText(/^Sends /)).toBeInTheDocument();
120
+ // it left the waiting-for-approval list
121
+ expect(screen.queryByRole('button', { name: /approve and send/i })).toBeNull();
122
+ });
123
+
124
+ it('makes no write until the form is submitted', async () => {
125
+ renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
126
+ await userEvent.click(await screen.findByRole('button', { name: /schedule/i }));
127
+ expect(screen.getByRole('button', { name: /approve and schedule/i })).toBeDisabled(); // no time yet
128
+ expect(writes()).toEqual([]);
129
+ });
130
+
131
+ it('shows the server reason when scheduling is refused', async () => {
132
+ conflict = true;
133
+ renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
134
+ await schedule();
135
+ expect((await screen.findByRole('alert')).textContent).toMatch(/already started/i);
136
+ });
137
+ });
138
+
139
+ describe('a scheduled draft', () => {
140
+ beforeEach(() => {
141
+ drafts = [draft({ approval: 'scheduled', send_at: '2026-11-03T15:00:00+00:00', send_timezone: 'America/New_York' })];
142
+ });
143
+
144
+ it('is listed with its send time, in the zone it was set in', async () => {
145
+ renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
146
+ expect(await screen.findByRole('heading', { name: /scheduled to send/i })).toBeInTheDocument();
147
+ expect(await screen.findByText(/^Sends .*(10:00|11:00)/)).toBeInTheDocument();
148
+ expect(screen.getByText('No updates are waiting for approval.')).toBeInTheDocument();
149
+ });
150
+
151
+ it('cancels the schedule and the draft is waiting for approval again', async () => {
152
+ renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
153
+ await userEvent.click(await screen.findByRole('button', { name: /cancel schedule/i }));
154
+ await waitFor(() => expect(writes()[0]).toMatchObject({ method: 'POST', path: '/drafts/d-1/cancel/' }));
155
+ expect(await screen.findByRole('button', { name: /approve and send/i })).toBeInTheDocument();
156
+ expect(await screen.findByText(/schedule cancelled/i)).toBeInTheDocument();
157
+ });
158
+
159
+ it('reschedules with a new time and zone', async () => {
160
+ renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
161
+ await userEvent.click(await screen.findByRole('button', { name: /^reschedule$/i }));
162
+ fireEvent.change(screen.getByLabelText('Send at'), { target: { value: '2026-11-05T10:00' } });
163
+ await userEvent.selectOptions(screen.getByLabelText('Time zone'), 'UTC');
164
+ await userEvent.click(screen.getByRole('button', { name: /move send/i }));
165
+ await waitFor(() =>
166
+ expect(writes()[0]).toMatchObject({
167
+ method: 'POST', path: '/drafts/d-1/reschedule/', body: { send_at: '2026-11-05T10:00', timezone: 'UTC' },
168
+ }),
169
+ );
170
+ });
171
+
172
+ it('sends now', async () => {
173
+ renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
174
+ await userEvent.click(await screen.findByRole('button', { name: /^send now$/i }));
175
+ await waitFor(() => expect(writes()[0]).toMatchObject({ method: 'POST', path: '/drafts/d-1/approve/' }));
176
+ expect(writes()[0].body).toBeUndefined();
177
+ });
178
+
179
+ it('shows the server reason when cancel is too late', async () => {
180
+ conflict = true;
181
+ renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
182
+ await userEvent.click(await screen.findByRole('button', { name: /cancel schedule/i }));
183
+ expect((await screen.findByRole('alert')).textContent).toMatch(/already started/i);
184
+ });
185
+ });
186
+
187
+ describe('send log', () => {
188
+ it('lists scheduled and cancelled drafts above the sent rows', async () => {
189
+ scheduledReport = [
190
+ { id: 's-1', program_key: 'product_update', subject: 'Tuesday news', status: 'scheduled',
191
+ send_at: '2026-11-03T15:00:00+00:00', send_timezone: 'UTC', cancelled_at: null },
192
+ { id: 's-2', program_key: 'announcement', subject: 'Dropped idea', status: 'cancelled',
193
+ send_at: null, send_timezone: null, cancelled_at: '2026-11-01T09:00:00+00:00' },
194
+ ];
195
+ renderWith((api) => <SendLog api={api} />);
196
+ const heading = await screen.findByRole('heading', { name: /scheduled and cancelled/i });
197
+ const table = within(heading.parentElement as HTMLElement);
198
+ expect(table.getByText('Tuesday news')).toBeInTheDocument();
199
+ expect(table.getByText('scheduled')).toBeInTheDocument();
200
+ expect(table.getByText('Dropped idea')).toBeInTheDocument();
201
+ expect(table.getByText('cancelled')).toBeInTheDocument();
202
+ expect(table.getByText(/^Cancelled /)).toBeInTheDocument();
203
+ });
204
+
205
+ it('shows no scheduled section when there is nothing scheduled', async () => {
206
+ renderWith((api) => <SendLog api={api} />);
207
+ expect(await screen.findByText(/no emails sent yet/i)).toBeInTheDocument();
208
+ expect(screen.queryByRole('heading', { name: /scheduled and cancelled/i })).toBeNull();
209
+ });
210
+ });
211
+
212
+ // ---- the closed loop: every state is named, with a reason and a next action ----
213
+
214
+ describe('states the admin has to act on', () => {
215
+ it('says when a past time was sent immediately', async () => {
216
+ renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
217
+ await schedule('2020-01-01T10:00', 'UTC');
218
+ expect(await screen.findByText(/sent immediately/i)).toBeInTheDocument();
219
+ expect(screen.getByText(/had already passed/i)).toBeInTheDocument();
220
+ });
221
+
222
+ it('explains the picker: a time that has passed sends now', async () => {
223
+ renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
224
+ await userEvent.click(await screen.findByRole('button', { name: /schedule/i }));
225
+ expect(screen.getByText(/already passed sends immediately/i)).toBeInTheDocument();
226
+ });
227
+
228
+ it('shows an expired send with its reason and original time, and can approve again', async () => {
229
+ drafts = [draft({
230
+ approval: 'expired', state: 'expired', next_actions: ['approve', 'schedule', 'edit', 'discard'],
231
+ notice: { code: 'expired', reason: 'comms were paused at the scheduled time', send_at: '2026-11-03T15:00:00+00:00' },
232
+ })];
233
+ renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
234
+ const heading = await screen.findByRole('heading', { name: /needs attention/i });
235
+ const area = within(heading.parentElement as HTMLElement);
236
+ expect(await area.findByText(/comms were paused at the scheduled time/i)).toBeInTheDocument();
237
+ expect(area.getByText(/was scheduled for/i)).toBeInTheDocument();
238
+ await userEvent.click(area.getByRole('button', { name: /approve and send/i }));
239
+ await waitFor(() => expect(writes()[0]).toMatchObject({ method: 'POST', path: '/drafts/d-1/approve/' }));
240
+ });
241
+
242
+ it('shows a failed send with the error and a Retry that posts retry', async () => {
243
+ drafts = [draft({
244
+ approval: 'failed', state: 'failed', next_actions: ['retry', 'approve', 'discard'],
245
+ failure: { stage: 'dispatch', code: 'audience_unavailable', reason: 'could not reach the foundry backend (after 3 attempts)', at: '2026-11-03T15:01:00+00:00' },
246
+ })];
247
+ renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
248
+ expect(await screen.findByText(/could not reach the foundry backend/i)).toBeInTheDocument();
249
+ await userEvent.click(screen.getByRole('button', { name: /^retry$/i }));
250
+ await waitFor(() => expect(writes()[0]).toMatchObject({ method: 'POST', path: '/drafts/d-1/retry/' }));
251
+ });
252
+
253
+ it('a send that failed after it started offers only Retry', async () => {
254
+ drafts = [draft({
255
+ approval: 'failed', state: 'failed', next_actions: ['retry'],
256
+ failure: { stage: 'send', code: 'recipients_not_sent', reason: '2 recipients were still unsent after 3 retries', at: '2026-11-03T15:01:00+00:00' },
257
+ })];
258
+ renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
259
+ expect(await screen.findByText(/2 recipients were still unsent/i)).toBeInTheDocument();
260
+ expect(screen.getByRole('button', { name: /^retry$/i })).toBeInTheDocument();
261
+ expect(screen.queryByRole('button', { name: /approve and send/i })).toBeNull();
262
+ expect(screen.queryByRole('button', { name: /^discard$/i })).toBeNull();
263
+ });
264
+
265
+ it('shows why an auto-approved draft is still waiting', async () => {
266
+ drafts = [draft({ notice: { code: 'no_recipients', reason: 'this audience has no recipients (2 opted out)' } })];
267
+ renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
268
+ expect(await screen.findByText(/no recipients \(2 opted out\)/i)).toBeInTheDocument();
269
+ });
270
+
271
+ it('shows a scheduled send that is being retried', async () => {
272
+ drafts = [draft({
273
+ approval: 'scheduled', send_at: '2026-11-03T15:00:00+00:00', send_timezone: 'UTC',
274
+ dispatch_attempts: 1, dispatch_error: 'could not reach the foundry backend',
275
+ })];
276
+ renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
277
+ expect(await screen.findByText(/trying again.*could not reach the foundry backend/i)).toBeInTheDocument();
278
+ });
279
+
280
+ it('shows both recipient counts when the audience changed since scheduling', async () => {
281
+ liveCount = 5;
282
+ drafts = [draft({ approval: 'scheduled', send_at: '2026-11-03T15:00:00+00:00', send_timezone: 'UTC', recipient_count: 2 })];
283
+ renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
284
+ expect(await screen.findByText(/2 recipients when scheduled, 5 now/i)).toBeInTheDocument();
285
+ });
286
+
287
+ it('shows one count when nothing changed', async () => {
288
+ drafts = [draft({ approval: 'scheduled', send_at: '2026-11-03T15:00:00+00:00', send_timezone: 'UTC', recipient_count: 2 })];
289
+ renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
290
+ expect(await screen.findByText(/^2 recipients$/i)).toBeInTheDocument();
291
+ });
292
+
293
+ it('the send log gives the reason for expired and failed drafts', async () => {
294
+ scheduledReport = [
295
+ { id: 'e-1', program_key: 'announcement', subject: 'Paused one', status: 'expired', reason: 'comms were paused at the scheduled time',
296
+ send_at: '2026-11-03T15:00:00+00:00', send_timezone: null, cancelled_at: null },
297
+ { id: 'f-1', program_key: 'announcement', subject: 'Broken one', status: 'failed', reason: 'this audience has no recipients',
298
+ send_at: null, send_timezone: null, cancelled_at: null },
299
+ ];
300
+ renderWith((api) => <SendLog api={api} />);
301
+ expect(await screen.findByText('comms were paused at the scheduled time')).toBeInTheDocument();
302
+ expect(screen.getByText('this audience has no recipients')).toBeInTheDocument();
303
+ });
304
+ });
@@ -28,7 +28,7 @@ export function setAuthTokenProvider(
28
28
  }
29
29
 
30
30
  export interface RequestOptions {
31
- method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
31
+ method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
32
32
  body?: unknown;
33
33
  query?: Record<string, string | number | boolean | undefined | null>;
34
34
  }
@@ -21,7 +21,10 @@ import type {
21
21
  CommsAsset,
22
22
  CommsSettings,
23
23
  CommsSettingsPatch,
24
+ AudienceSegments,
25
+ AudienceSpec,
24
26
  Draft,
27
+ DraftAudience,
25
28
  DraftCreate,
26
29
  DraftPatch,
27
30
  DraftsQuery,
@@ -31,7 +34,8 @@ import type {
31
34
  ProgramUpdateResult,
32
35
  RenderedEmail,
33
36
  SendNowResult,
34
- SendRow,
37
+ SendsPage,
38
+ ScheduleInput,
35
39
  SendsQuery,
36
40
  StepGeneration,
37
41
  TestSendInput,
@@ -73,13 +77,25 @@ export interface CommsApi {
73
77
  uploadAsset(file: File, alt: string): Promise<CommsAsset>;
74
78
  deleteAsset(id: string): Promise<void>;
75
79
  updateDraft(id: string, patch: DraftPatch): Promise<Draft>;
76
- approveDraft(id: string): Promise<Draft>;
80
+ /** Without `schedule` the send is queued now; with it the draft is scheduled (status 'scheduled'). */
81
+ approveDraft(id: string, schedule?: ScheduleInput): Promise<Draft>;
82
+ /** Move a still-scheduled draft; 409 once the send has started. */
83
+ rescheduleDraft(id: string, schedule: ScheduleInput): Promise<Draft>;
84
+ /** Scheduled -> pending again; 409 once the send has started. */
85
+ cancelDraft(id: string): Promise<Draft>;
86
+ /** Resume a failed draft (re-run the dispatch, or re-queue the unsent recipients). */
87
+ retryDraft(id: string): Promise<Draft>;
77
88
  discardDraft(id: string): Promise<Draft>;
78
89
  previewDraft(id: string): Promise<RenderedEmail>;
90
+ // per-send audience: the draft's own spec (or the program default) + who it reaches
91
+ getDraftAudience(id: string): Promise<DraftAudience>;
92
+ saveDraftAudience(id: string, audience: AudienceSpec): Promise<DraftAudience>;
93
+ previewDraftAudience(id: string, audience: AudienceSpec): Promise<DraftAudience>;
94
+ listAudienceSegments(): Promise<AudienceSegments>;
79
95
  sendUpdateNow(input?: { notes?: string }): Promise<SendNowResult>;
80
96
 
81
97
  // results
82
- listSends(query?: SendsQuery): Promise<Paginated<SendRow>>;
98
+ listSends(query?: SendsQuery): Promise<SendsPage>;
83
99
  getMetrics(query?: MetricsQuery): Promise<CommsMetrics>;
84
100
  }
85
101
 
@@ -126,9 +142,19 @@ export function createCommsApi(slug: string): CommsApi {
126
142
  },
127
143
  deleteAsset: (id) => request(`${P}/assets/${seg(id)}/`, { method: 'DELETE' }),
128
144
  updateDraft: (id, patch) => request(draftPath(id), { method: 'PATCH', body: patch }),
129
- approveDraft: (id) => request(draftPath(id, 'approve'), { method: 'POST' }),
145
+ approveDraft: (id, schedule) =>
146
+ request(draftPath(id, 'approve'), schedule ? { method: 'POST', body: schedule } : { method: 'POST' }),
147
+ rescheduleDraft: (id, schedule) => request(draftPath(id, 'reschedule'), { method: 'POST', body: schedule }),
148
+ cancelDraft: (id) => request(draftPath(id, 'cancel'), { method: 'POST' }),
149
+ retryDraft: (id) => request(draftPath(id, 'retry'), { method: 'POST' }),
130
150
  discardDraft: (id) => request(draftPath(id, 'discard'), { method: 'POST' }),
131
151
  previewDraft: (id) => request(draftPath(id, 'preview')),
152
+ getDraftAudience: (id) => request(draftPath(id, 'audience')),
153
+ saveDraftAudience: (id, audience) =>
154
+ request(draftPath(id, 'audience'), { method: 'PUT', body: { audience } }),
155
+ previewDraftAudience: (id, audience) =>
156
+ request(draftPath(id, 'audience/preview'), { method: 'POST', body: { audience } }),
157
+ listAudienceSegments: () => request(`${P}/audience/segments/`),
132
158
  sendUpdateNow: (input = {}) =>
133
159
  request(`${P}/programs/product_update/send-now/`, { method: 'POST', body: input }),
134
160