@startsimpli/integrations 0.2.47 → 0.2.49
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 +3 -3
- package/src/comms/__tests__/announcement-composer.test.tsx +2 -1
- package/src/comms/__tests__/scheduled-send.test.tsx +304 -0
- package/src/comms/api/comms.ts +16 -4
- package/src/comms/components/DraftReview.tsx +138 -37
- package/src/comms/components/SchedulePicker.tsx +53 -0
- package/src/comms/components/ScheduledSends.tsx +103 -0
- package/src/comms/components/SendLog.tsx +36 -1
- package/src/comms/components/_shared.tsx +35 -0
- package/src/comms/components/index.ts +2 -0
- package/src/comms/hooks/index.ts +36 -4
- package/src/comms/types/index.ts +70 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/integrations",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.49",
|
|
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",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"react": ">=18.0.0",
|
|
36
36
|
"@tanstack/react-query": ">=5.0.0",
|
|
37
37
|
"@startsimpli/llm": ">=0.4.0",
|
|
38
|
-
"@startsimpli/ui": "^0.4.
|
|
38
|
+
"@startsimpli/ui": "^0.4.163"
|
|
39
39
|
},
|
|
40
40
|
"peerDependenciesMeta": {
|
|
41
41
|
"react": {
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"typescript": "^6.0.0",
|
|
66
66
|
"vitest": "^3.0.0",
|
|
67
67
|
"@startsimpli/llm": "0.4.25",
|
|
68
|
-
"@startsimpli/ui": "0.4.
|
|
68
|
+
"@startsimpli/ui": "0.4.163"
|
|
69
69
|
},
|
|
70
70
|
"scripts": {
|
|
71
71
|
"type-check": "tsc --noEmit",
|
|
@@ -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
|
|
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\/([^/]+)\/$/);
|
|
@@ -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
|
+
});
|
package/src/comms/api/comms.ts
CHANGED
|
@@ -34,7 +34,8 @@ import type {
|
|
|
34
34
|
ProgramUpdateResult,
|
|
35
35
|
RenderedEmail,
|
|
36
36
|
SendNowResult,
|
|
37
|
-
|
|
37
|
+
SendsPage,
|
|
38
|
+
ScheduleInput,
|
|
38
39
|
SendsQuery,
|
|
39
40
|
StepGeneration,
|
|
40
41
|
TestSendInput,
|
|
@@ -76,7 +77,14 @@ export interface CommsApi {
|
|
|
76
77
|
uploadAsset(file: File, alt: string): Promise<CommsAsset>;
|
|
77
78
|
deleteAsset(id: string): Promise<void>;
|
|
78
79
|
updateDraft(id: string, patch: DraftPatch): Promise<Draft>;
|
|
79
|
-
|
|
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>;
|
|
80
88
|
discardDraft(id: string): Promise<Draft>;
|
|
81
89
|
previewDraft(id: string): Promise<RenderedEmail>;
|
|
82
90
|
// per-send audience: the draft's own spec (or the program default) + who it reaches
|
|
@@ -87,7 +95,7 @@ export interface CommsApi {
|
|
|
87
95
|
sendUpdateNow(input?: { notes?: string }): Promise<SendNowResult>;
|
|
88
96
|
|
|
89
97
|
// results
|
|
90
|
-
listSends(query?: SendsQuery): Promise<
|
|
98
|
+
listSends(query?: SendsQuery): Promise<SendsPage>;
|
|
91
99
|
getMetrics(query?: MetricsQuery): Promise<CommsMetrics>;
|
|
92
100
|
}
|
|
93
101
|
|
|
@@ -134,7 +142,11 @@ export function createCommsApi(slug: string): CommsApi {
|
|
|
134
142
|
},
|
|
135
143
|
deleteAsset: (id) => request(`${P}/assets/${seg(id)}/`, { method: 'DELETE' }),
|
|
136
144
|
updateDraft: (id, patch) => request(draftPath(id), { method: 'PATCH', body: patch }),
|
|
137
|
-
approveDraft: (id
|
|
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' }),
|
|
138
150
|
discardDraft: (id) => request(draftPath(id, 'discard'), { method: 'POST' }),
|
|
139
151
|
previewDraft: (id) => request(draftPath(id, 'preview')),
|
|
140
152
|
getDraftAudience: (id) => request(draftPath(id, 'audience')),
|
|
@@ -14,15 +14,18 @@ import {
|
|
|
14
14
|
DEFAULT_POLL_MS,
|
|
15
15
|
useApproveDraft,
|
|
16
16
|
useDiscardDraft,
|
|
17
|
+
useRetryDraft,
|
|
17
18
|
useDraftPreview,
|
|
18
19
|
useDrafts,
|
|
19
20
|
useSendTest,
|
|
20
21
|
useUpdateDraft,
|
|
21
22
|
} from '../hooks/index';
|
|
22
|
-
import type { Draft, DraftPatch } from '../types/index';
|
|
23
|
+
import type { Draft, DraftAction, DraftPatch } from '../types/index';
|
|
23
24
|
import { AudiencePanel } from './AudiencePanel';
|
|
24
25
|
import { EmailPreview } from './EmailPreview';
|
|
25
|
-
import {
|
|
26
|
+
import { SchedulePicker } from './SchedulePicker';
|
|
27
|
+
import { ScheduledSends } from './ScheduledSends';
|
|
28
|
+
import { ErrorNotice, Note, formatDate, formatDateTime, humanize, useFieldId } from './_shared';
|
|
26
29
|
|
|
27
30
|
export interface DraftReviewProps {
|
|
28
31
|
api: CommsApi;
|
|
@@ -79,15 +82,36 @@ function DraftEditor({ api, draft, onDone }: { api: CommsApi; draft: Draft; onDo
|
|
|
79
82
|
);
|
|
80
83
|
}
|
|
81
84
|
|
|
82
|
-
|
|
85
|
+
/** The actions the server says are open; older servers send none, which means the pending set. */
|
|
86
|
+
function allowed(draft: Draft, action: DraftAction): boolean {
|
|
87
|
+
if (!draft.next_actions) return action !== 'retry';
|
|
88
|
+
return draft.next_actions.includes(action);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function DraftItem({
|
|
92
|
+
api,
|
|
93
|
+
draft,
|
|
94
|
+
onSentImmediately,
|
|
95
|
+
}: {
|
|
96
|
+
api: CommsApi;
|
|
97
|
+
draft: Draft;
|
|
98
|
+
onSentImmediately?: (draft: Draft) => void;
|
|
99
|
+
}) {
|
|
83
100
|
const [editing, setEditing] = useState(false);
|
|
84
101
|
const [previewing, setPreviewing] = useState(false);
|
|
85
102
|
const [auditing, setAuditing] = useState(false);
|
|
103
|
+
const [scheduling, setScheduling] = useState(false);
|
|
86
104
|
const ready = !draft.generating && !draft.error;
|
|
87
105
|
const preview = useDraftPreview(api, draft.id, previewing && ready);
|
|
88
106
|
const sendTest = useSendTest(api);
|
|
89
107
|
const approve = useApproveDraft(api);
|
|
90
108
|
const discard = useDiscardDraft(api);
|
|
109
|
+
const retry = useRetryDraft(api);
|
|
110
|
+
const approveWith = (vars: Parameters<typeof approve.mutateAsync>[0], done?: () => void) =>
|
|
111
|
+
approve.mutateAsync(vars).then((d) => {
|
|
112
|
+
if (d.sent_immediately) onSentImmediately?.(d);
|
|
113
|
+
done?.();
|
|
114
|
+
}, () => undefined);
|
|
91
115
|
|
|
92
116
|
return (
|
|
93
117
|
<li className="py-3">
|
|
@@ -100,16 +124,20 @@ function DraftItem({ api, draft }: { api: CommsApi; draft: Draft }) {
|
|
|
100
124
|
{ready ? (
|
|
101
125
|
<>
|
|
102
126
|
<Button type="button" size="sm" variant="outline" onClick={() => setPreviewing((v) => !v)}>Preview</Button>
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
127
|
+
{allowed(draft, 'edit') ? (
|
|
128
|
+
<>
|
|
129
|
+
<Button type="button" size="sm" variant="outline" onClick={() => setEditing((v) => !v)}>Edit</Button>
|
|
130
|
+
<Button
|
|
131
|
+
type="button"
|
|
132
|
+
size="sm"
|
|
133
|
+
variant="outline"
|
|
134
|
+
aria-expanded={auditing}
|
|
135
|
+
onClick={() => setAuditing((v) => !v)}
|
|
136
|
+
>
|
|
137
|
+
Audience
|
|
138
|
+
</Button>
|
|
139
|
+
</>
|
|
140
|
+
) : null}
|
|
113
141
|
<Button
|
|
114
142
|
type="button"
|
|
115
143
|
size="sm"
|
|
@@ -119,12 +147,24 @@ function DraftItem({ api, draft }: { api: CommsApi; draft: Draft }) {
|
|
|
119
147
|
>
|
|
120
148
|
Send test to me
|
|
121
149
|
</Button>
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
150
|
+
{allowed(draft, 'approve') ? (
|
|
151
|
+
<Button type="button" size="sm" disabled={approve.isPending} onClick={() => approveWith(draft.id)}>
|
|
152
|
+
Approve and send
|
|
153
|
+
</Button>
|
|
154
|
+
) : null}
|
|
155
|
+
{allowed(draft, 'schedule') ? (
|
|
156
|
+
<Button type="button" size="sm" variant="outline" onClick={() => setScheduling((v) => !v)}>
|
|
157
|
+
Schedule…
|
|
158
|
+
</Button>
|
|
159
|
+
) : null}
|
|
160
|
+
{allowed(draft, 'retry') ? (
|
|
161
|
+
<Button type="button" size="sm" disabled={retry.isPending} onClick={() => retry.mutate(draft.id)}>
|
|
162
|
+
Retry
|
|
163
|
+
</Button>
|
|
164
|
+
) : null}
|
|
125
165
|
</>
|
|
126
166
|
) : null}
|
|
127
|
-
{!draft.generating ? (
|
|
167
|
+
{!draft.generating && allowed(draft, 'discard') ? (
|
|
128
168
|
<Button type="button" size="sm" variant="ghost" disabled={discard.isPending} onClick={() => discard.mutate(draft.id)}>
|
|
129
169
|
Discard
|
|
130
170
|
</Button>
|
|
@@ -136,39 +176,100 @@ function DraftItem({ api, draft }: { api: CommsApi; draft: Draft }) {
|
|
|
136
176
|
{sendTest.data && !sendTest.data.sent ? (
|
|
137
177
|
<ErrorNotice error={new Error(`Test not sent: ${humanize(sendTest.data.reason || 'refused')}`)} />
|
|
138
178
|
) : null}
|
|
139
|
-
<ErrorNotice error={
|
|
179
|
+
{draft.failure ? <ErrorNotice error={new Error(`Send failed: ${draft.failure.reason}`)} /> : null}
|
|
180
|
+
{draft.notice ? (
|
|
181
|
+
<Note>
|
|
182
|
+
{draft.notice.code === 'expired' && draft.notice.send_at
|
|
183
|
+
? `${draft.notice.reason}. It was scheduled for ${formatDateTime(draft.notice.send_at)}.`
|
|
184
|
+
: draft.notice.reason}
|
|
185
|
+
</Note>
|
|
186
|
+
) : null}
|
|
187
|
+
<ErrorNotice error={sendTest.error ?? approve.error ?? discard.error ?? retry.error ?? preview.error} />
|
|
140
188
|
{editing && ready ? <DraftEditor api={api} draft={draft} onDone={() => setEditing(false)} /> : null}
|
|
141
189
|
{auditing && ready ? <AudiencePanel api={api} draftId={draft.id} /> : null}
|
|
142
190
|
{previewing && preview.data ? <EmailPreview email={preview.data} /> : null}
|
|
191
|
+
{scheduling && ready ? (
|
|
192
|
+
<SchedulePicker
|
|
193
|
+
submitLabel="Approve and schedule"
|
|
194
|
+
pending={approve.isPending}
|
|
195
|
+
onCancel={() => setScheduling(false)}
|
|
196
|
+
onSubmit={(s) => approveWith({ id: draft.id, ...s }, () => setScheduling(false))}
|
|
197
|
+
/>
|
|
198
|
+
) : null}
|
|
199
|
+
{draft.cancelled_at ? <Note>{`Schedule cancelled ${formatDateTime(draft.cancelled_at)}.`}</Note> : null}
|
|
143
200
|
</li>
|
|
144
201
|
);
|
|
145
202
|
}
|
|
146
203
|
|
|
204
|
+
/** Expired and failed drafts: each states why and offers its next action. */
|
|
205
|
+
function NeedsAttention({ api, pollIntervalMs, onSentImmediately }: {
|
|
206
|
+
api: CommsApi;
|
|
207
|
+
pollIntervalMs: number;
|
|
208
|
+
onSentImmediately: (draft: Draft) => void;
|
|
209
|
+
}): ReactElement | null {
|
|
210
|
+
const expired = useDrafts(api, { status: 'expired', page: 1, page_size: 50 }, pollIntervalMs);
|
|
211
|
+
const failed = useDrafts(api, { status: 'failed', page: 1, page_size: 50 }, pollIntervalMs);
|
|
212
|
+
const rows = [...(failed.data?.results ?? []), ...(expired.data?.results ?? [])];
|
|
213
|
+
if (!rows.length) return null;
|
|
214
|
+
return (
|
|
215
|
+
<div className="space-y-1">
|
|
216
|
+
<h3 className="text-sm font-medium">Needs attention</h3>
|
|
217
|
+
<ul className="divide-y">
|
|
218
|
+
{rows.map((d) => (
|
|
219
|
+
<DraftItem key={d.id} api={api} draft={d} onSentImmediately={onSentImmediately} />
|
|
220
|
+
))}
|
|
221
|
+
</ul>
|
|
222
|
+
</div>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
147
226
|
export function DraftReview({ api, pollIntervalMs = DEFAULT_POLL_MS, pageSize = 10 }: DraftReviewProps): ReactElement | null {
|
|
148
227
|
const [page, setPage] = useState(1);
|
|
228
|
+
const [justSent, setJustSent] = useState<Draft | null>(null);
|
|
149
229
|
const drafts = useDrafts(api, { status: 'pending', page, page_size: pageSize }, pollIntervalMs);
|
|
150
230
|
if (drafts.error) return <ErrorNotice error={drafts.error} />;
|
|
151
231
|
if (!drafts.data) return null;
|
|
152
232
|
const { results, count } = drafts.data;
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
<DraftItem key={d.id} api={api} draft={d} />
|
|
160
|
-
))}
|
|
161
|
-
</ul>
|
|
162
|
-
{count > pageSize ? (
|
|
163
|
-
<div className="flex items-center gap-2 text-sm">
|
|
164
|
-
<Button type="button" size="sm" variant="outline" disabled={!drafts.data.previous} onClick={() => setPage(page - 1)}>
|
|
165
|
-
Previous
|
|
166
|
-
</Button>
|
|
167
|
-
<Button type="button" size="sm" variant="outline" disabled={!drafts.data.next} onClick={() => setPage(page + 1)}>
|
|
168
|
-
Next
|
|
169
|
-
</Button>
|
|
170
|
-
</div>
|
|
233
|
+
const extras = (
|
|
234
|
+
<>
|
|
235
|
+
{justSent ? (
|
|
236
|
+
<Note>
|
|
237
|
+
{`"${justSent.subject}" was sent immediately: the requested time (${formatDateTime(justSent.requested_send_at)}) had already passed.`}
|
|
238
|
+
</Note>
|
|
171
239
|
) : null}
|
|
172
|
-
|
|
240
|
+
<NeedsAttention api={api} pollIntervalMs={pollIntervalMs} onSentImmediately={setJustSent} />
|
|
241
|
+
<ScheduledSends api={api} pollIntervalMs={pollIntervalMs} />
|
|
242
|
+
</>
|
|
243
|
+
);
|
|
244
|
+
if (!results.length) {
|
|
245
|
+
return (
|
|
246
|
+
<>
|
|
247
|
+
<Note>No updates are waiting for approval.</Note>
|
|
248
|
+
{extras}
|
|
249
|
+
</>
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
return (
|
|
253
|
+
<>
|
|
254
|
+
<div className="space-y-1">
|
|
255
|
+
<h3 className="text-sm font-medium">Waiting for approval</h3>
|
|
256
|
+
<ul className="divide-y">
|
|
257
|
+
{results.map((d) => (
|
|
258
|
+
<DraftItem key={d.id} api={api} draft={d} onSentImmediately={setJustSent} />
|
|
259
|
+
))}
|
|
260
|
+
</ul>
|
|
261
|
+
{count > pageSize ? (
|
|
262
|
+
<div className="flex items-center gap-2 text-sm">
|
|
263
|
+
<Button type="button" size="sm" variant="outline" disabled={!drafts.data.previous} onClick={() => setPage(page - 1)}>
|
|
264
|
+
Previous
|
|
265
|
+
</Button>
|
|
266
|
+
<Button type="button" size="sm" variant="outline" disabled={!drafts.data.next} onClick={() => setPage(page + 1)}>
|
|
267
|
+
Next
|
|
268
|
+
</Button>
|
|
269
|
+
</div>
|
|
270
|
+
) : null}
|
|
271
|
+
</div>
|
|
272
|
+
{extras}
|
|
273
|
+
</>
|
|
173
274
|
);
|
|
174
275
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pick when an approved draft goes out: a local date and time plus the time
|
|
3
|
+
* zone it is meant in (default: the viewer's). The server stores the instant in
|
|
4
|
+
* UTC; this form only collects the wall-clock time and the zone name.
|
|
5
|
+
*/
|
|
6
|
+
import { useState, type ReactElement } from 'react';
|
|
7
|
+
import { Button, Input, Label } from '@startsimpli/ui';
|
|
8
|
+
|
|
9
|
+
import type { ScheduleInput } from '../types/index';
|
|
10
|
+
import { NativeSelect, localTimeZone, timeZoneOptions, useFieldId } from './_shared';
|
|
11
|
+
|
|
12
|
+
export interface SchedulePickerProps {
|
|
13
|
+
submitLabel: string;
|
|
14
|
+
pending?: boolean;
|
|
15
|
+
onSubmit: (schedule: ScheduleInput) => void;
|
|
16
|
+
onCancel: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function SchedulePicker({ submitLabel, pending, onSubmit, onCancel }: SchedulePickerProps): ReactElement {
|
|
20
|
+
const [when, setWhen] = useState('');
|
|
21
|
+
const [zone, setZone] = useState(localTimeZone);
|
|
22
|
+
const whenId = useFieldId('comms-send-at');
|
|
23
|
+
const zoneId = useFieldId('comms-send-zone');
|
|
24
|
+
return (
|
|
25
|
+
<form
|
|
26
|
+
className="mt-2 flex flex-wrap items-end gap-3 rounded-md border p-3"
|
|
27
|
+
onSubmit={(e) => {
|
|
28
|
+
e.preventDefault();
|
|
29
|
+
if (when) onSubmit({ send_at: when, timezone: zone });
|
|
30
|
+
}}
|
|
31
|
+
>
|
|
32
|
+
<div className="space-y-1">
|
|
33
|
+
<Label htmlFor={whenId}>Send at</Label>
|
|
34
|
+
<Input id={whenId} type="datetime-local" required value={when} onChange={(e) => setWhen(e.target.value)} />
|
|
35
|
+
</div>
|
|
36
|
+
<div className="space-y-1">
|
|
37
|
+
<Label htmlFor={zoneId}>Time zone</Label>
|
|
38
|
+
<div>
|
|
39
|
+
<NativeSelect id={zoneId} value={zone} onChange={(e) => setZone(e.target.value)}>
|
|
40
|
+
{timeZoneOptions().map((z) => (
|
|
41
|
+
<option key={z} value={z}>{z}</option>
|
|
42
|
+
))}
|
|
43
|
+
</NativeSelect>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
<p className="basis-full text-xs text-muted-foreground">
|
|
47
|
+
A time that has already passed sends immediately.
|
|
48
|
+
</p>
|
|
49
|
+
<Button type="submit" size="sm" disabled={pending || !when}>{submitLabel}</Button>
|
|
50
|
+
<Button type="button" size="sm" variant="ghost" onClick={onCancel}>Close</Button>
|
|
51
|
+
</form>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approved drafts waiting for their send time: when they go out, and the three
|
|
3
|
+
* things you can still do before then (reschedule, send now, cancel). Once the
|
|
4
|
+
* send starts the server refuses cancel and reschedule (409), shown as-is.
|
|
5
|
+
*/
|
|
6
|
+
import { useState, type ReactElement } from 'react';
|
|
7
|
+
import { Button } from '@startsimpli/ui';
|
|
8
|
+
|
|
9
|
+
import type { CommsApi } from '../api/comms';
|
|
10
|
+
import {
|
|
11
|
+
DEFAULT_POLL_MS,
|
|
12
|
+
useApproveDraft,
|
|
13
|
+
useCancelDraft,
|
|
14
|
+
useDraftAudience,
|
|
15
|
+
useDrafts,
|
|
16
|
+
useRescheduleDraft,
|
|
17
|
+
} from '../hooks/index';
|
|
18
|
+
import type { Draft } from '../types/index';
|
|
19
|
+
import { SchedulePicker } from './SchedulePicker';
|
|
20
|
+
import { ErrorNotice, formatDateTime } from './_shared';
|
|
21
|
+
|
|
22
|
+
export interface ScheduledSendsProps {
|
|
23
|
+
api: CommsApi;
|
|
24
|
+
pollIntervalMs?: number;
|
|
25
|
+
/** Drafts per server page. Default 10. */
|
|
26
|
+
pageSize?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function ScheduledItem({ api, draft }: { api: CommsApi; draft: Draft }) {
|
|
30
|
+
const [moving, setMoving] = useState(false);
|
|
31
|
+
const reschedule = useRescheduleDraft(api);
|
|
32
|
+
const cancel = useCancelDraft(api);
|
|
33
|
+
const sendNow = useApproveDraft(api);
|
|
34
|
+
const zone = draft.send_timezone || undefined;
|
|
35
|
+
const live = useDraftAudience(api, draft.id, true).data?.preview.count;
|
|
36
|
+
const frozen = draft.recipient_count;
|
|
37
|
+
const count =
|
|
38
|
+
live === undefined
|
|
39
|
+
? null
|
|
40
|
+
: frozen != null && frozen !== live
|
|
41
|
+
? `${frozen} recipients when scheduled, ${live} now`
|
|
42
|
+
: `${live} recipients`;
|
|
43
|
+
return (
|
|
44
|
+
<li className="py-3">
|
|
45
|
+
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
|
46
|
+
<span className="min-w-[10rem] flex-1 font-medium">{draft.subject || '(no subject)'}</span>
|
|
47
|
+
<span className="text-sm text-muted-foreground">
|
|
48
|
+
{`Sends ${formatDateTime(draft.send_at, zone)}`}
|
|
49
|
+
{zone && zone !== 'UTC' ? ` (${formatDateTime(draft.send_at, 'UTC')})` : ''}
|
|
50
|
+
</span>
|
|
51
|
+
<div className="flex flex-wrap gap-2">
|
|
52
|
+
<Button type="button" size="sm" variant="outline" onClick={() => setMoving((v) => !v)}>Reschedule</Button>
|
|
53
|
+
<Button type="button" size="sm" variant="outline" disabled={sendNow.isPending} onClick={() => sendNow.mutate(draft.id)}>
|
|
54
|
+
Send now
|
|
55
|
+
</Button>
|
|
56
|
+
<Button type="button" size="sm" variant="ghost" disabled={cancel.isPending} onClick={() => cancel.mutate(draft.id)}>
|
|
57
|
+
Cancel schedule
|
|
58
|
+
</Button>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
{count ? <p className="text-sm text-muted-foreground">{count}</p> : null}
|
|
62
|
+
{draft.dispatch_error ? (
|
|
63
|
+
<p className="text-sm text-muted-foreground">{`Trying again: ${draft.dispatch_error}`}</p>
|
|
64
|
+
) : null}
|
|
65
|
+
{moving ? (
|
|
66
|
+
<SchedulePicker
|
|
67
|
+
submitLabel="Move send"
|
|
68
|
+
pending={reschedule.isPending}
|
|
69
|
+
onCancel={() => setMoving(false)}
|
|
70
|
+
onSubmit={(s) => reschedule.mutate({ id: draft.id, ...s }, { onSuccess: () => setMoving(false) })}
|
|
71
|
+
/>
|
|
72
|
+
) : null}
|
|
73
|
+
<ErrorNotice error={reschedule.error ?? cancel.error ?? sendNow.error} />
|
|
74
|
+
</li>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function ScheduledSends({ api, pollIntervalMs = DEFAULT_POLL_MS, pageSize = 10 }: ScheduledSendsProps): ReactElement | null {
|
|
79
|
+
const [page, setPage] = useState(1);
|
|
80
|
+
const drafts = useDrafts(api, { status: 'scheduled', page, page_size: pageSize }, pollIntervalMs);
|
|
81
|
+
if (drafts.error) return <ErrorNotice error={drafts.error} />;
|
|
82
|
+
if (!drafts.data?.results.length) return null;
|
|
83
|
+
return (
|
|
84
|
+
<div className="space-y-1">
|
|
85
|
+
<h3 className="text-sm font-medium">Scheduled to send</h3>
|
|
86
|
+
<ul className="divide-y">
|
|
87
|
+
{drafts.data.results.map((d) => (
|
|
88
|
+
<ScheduledItem key={d.id} api={api} draft={d} />
|
|
89
|
+
))}
|
|
90
|
+
</ul>
|
|
91
|
+
{drafts.data.count > pageSize ? (
|
|
92
|
+
<div className="flex items-center gap-2 text-sm">
|
|
93
|
+
<Button type="button" size="sm" variant="outline" disabled={!drafts.data.previous} onClick={() => setPage(page - 1)}>
|
|
94
|
+
Previous
|
|
95
|
+
</Button>
|
|
96
|
+
<Button type="button" size="sm" variant="outline" disabled={!drafts.data.next} onClick={() => setPage(page + 1)}>
|
|
97
|
+
Next
|
|
98
|
+
</Button>
|
|
99
|
+
</div>
|
|
100
|
+
) : null}
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
import type { CommsApi } from '../api/comms';
|
|
19
19
|
import { useSends } from '../hooks/index';
|
|
20
20
|
import { COMEBACK_PROGRAM_KEY, PRODUCT_UPDATE_PROGRAM_KEY } from '../types/index';
|
|
21
|
-
import { ErrorNotice, NativeSelect, formatDate, useFieldId } from './_shared';
|
|
21
|
+
import { ErrorNotice, NativeSelect, formatDate, formatDateTime, useFieldId } from './_shared';
|
|
22
22
|
|
|
23
23
|
export interface SendLogProps {
|
|
24
24
|
api: CommsApi;
|
|
@@ -73,6 +73,41 @@ export function SendLog({ api, pageSize = 25 }: SendLogProps): ReactElement {
|
|
|
73
73
|
</div>
|
|
74
74
|
</div>
|
|
75
75
|
<ErrorNotice error={sends.error} />
|
|
76
|
+
{data?.scheduled?.length ? (
|
|
77
|
+
<div className="space-y-1">
|
|
78
|
+
<h3 className="text-sm font-medium">Scheduled and cancelled</h3>
|
|
79
|
+
<Table>
|
|
80
|
+
<TableHeader>
|
|
81
|
+
<TableRow>
|
|
82
|
+
<TableHead>Subject</TableHead>
|
|
83
|
+
<TableHead>Program</TableHead>
|
|
84
|
+
<TableHead>Status</TableHead>
|
|
85
|
+
<TableHead>Goes out</TableHead>
|
|
86
|
+
<TableHead>Reason</TableHead>
|
|
87
|
+
</TableRow>
|
|
88
|
+
</TableHeader>
|
|
89
|
+
<TableBody>
|
|
90
|
+
{data.scheduled.map((s) => (
|
|
91
|
+
<TableRow key={s.id}>
|
|
92
|
+
<TableCell>{s.subject}</TableCell>
|
|
93
|
+
<TableCell>{programLabel(s.program_key)}</TableCell>
|
|
94
|
+
<TableCell>{s.status}</TableCell>
|
|
95
|
+
<TableCell>
|
|
96
|
+
{s.status === 'scheduled'
|
|
97
|
+
? formatDateTime(s.send_at, s.send_timezone)
|
|
98
|
+
: s.status === 'cancelled'
|
|
99
|
+
? `Cancelled ${formatDateTime(s.cancelled_at)}`
|
|
100
|
+
: s.send_at
|
|
101
|
+
? `Was ${formatDateTime(s.send_at, s.send_timezone)}`
|
|
102
|
+
: ''}
|
|
103
|
+
</TableCell>
|
|
104
|
+
<TableCell>{s.reason}</TableCell>
|
|
105
|
+
</TableRow>
|
|
106
|
+
))}
|
|
107
|
+
</TableBody>
|
|
108
|
+
</Table>
|
|
109
|
+
</div>
|
|
110
|
+
) : null}
|
|
76
111
|
<Table>
|
|
77
112
|
<TableHeader>
|
|
78
113
|
<TableRow>
|
|
@@ -41,6 +41,41 @@ export function formatDate(iso: string | null | undefined): string {
|
|
|
41
41
|
return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/** "Nov 3, 2026, 10:00 AM EST" in `timeZone` (IANA), or the viewer's zone when absent or unknown. */
|
|
45
|
+
export function formatDateTime(iso: string | null | undefined, timeZone?: string | null): string {
|
|
46
|
+
if (!iso) return '';
|
|
47
|
+
const d = new Date(iso);
|
|
48
|
+
if (Number.isNaN(d.getTime())) return '';
|
|
49
|
+
const opts: Intl.DateTimeFormatOptions = {
|
|
50
|
+
year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZoneName: 'short',
|
|
51
|
+
};
|
|
52
|
+
try {
|
|
53
|
+
return d.toLocaleString(undefined, timeZone ? { ...opts, timeZone } : opts);
|
|
54
|
+
} catch {
|
|
55
|
+
return d.toLocaleString(undefined, opts);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The viewer's IANA zone, or 'UTC' when the runtime cannot say. */
|
|
60
|
+
export function localTimeZone(): string {
|
|
61
|
+
try {
|
|
62
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
|
63
|
+
} catch {
|
|
64
|
+
return 'UTC';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** IANA zones for a picker: the runtime's list when it has one, always with the viewer's zone and UTC. */
|
|
69
|
+
export function timeZoneOptions(): string[] {
|
|
70
|
+
let zones: string[] = [];
|
|
71
|
+
try {
|
|
72
|
+
zones = (Intl as unknown as { supportedValuesOf?: (k: string) => string[] }).supportedValuesOf?.('timeZone') ?? [];
|
|
73
|
+
} catch {
|
|
74
|
+
zones = [];
|
|
75
|
+
}
|
|
76
|
+
return Array.from(new Set([localTimeZone(), 'UTC', ...zones]));
|
|
77
|
+
}
|
|
78
|
+
|
|
44
79
|
/** A readable reason code: "not_allowlisted" -> "not allowlisted". */
|
|
45
80
|
export function humanize(code: string): string {
|
|
46
81
|
return code.replace(/_/g, ' ');
|
|
@@ -7,6 +7,8 @@ export * from './VoiceSettings';
|
|
|
7
7
|
export * from './SendingDomain';
|
|
8
8
|
export * from './ProductUpdates';
|
|
9
9
|
export * from './DraftReview';
|
|
10
|
+
export * from './SchedulePicker';
|
|
11
|
+
export * from './ScheduledSends';
|
|
10
12
|
export * from './ResultsPanel';
|
|
11
13
|
export * from './SendLog';
|
|
12
14
|
export * from './AudiencePanel';
|
package/src/comms/hooks/index.ts
CHANGED
|
@@ -38,7 +38,8 @@ import type {
|
|
|
38
38
|
ProgramUpdateResult,
|
|
39
39
|
RenderedEmail,
|
|
40
40
|
SendNowResult,
|
|
41
|
-
|
|
41
|
+
ScheduleInput,
|
|
42
|
+
SendsPage,
|
|
42
43
|
SendsQuery,
|
|
43
44
|
StepGeneration,
|
|
44
45
|
TestSendInput,
|
|
@@ -274,8 +275,39 @@ export function useUpdateDraft(api: CommsApi): UseMutationResult<Draft, Error, {
|
|
|
274
275
|
});
|
|
275
276
|
}
|
|
276
277
|
|
|
277
|
-
|
|
278
|
-
|
|
278
|
+
/** A draft id to send now, or {id, ...schedule} to schedule it. */
|
|
279
|
+
export type ApproveDraftVars = string | ({ id: string } & Partial<ScheduleInput>);
|
|
280
|
+
|
|
281
|
+
export function useApproveDraft(api: CommsApi): UseMutationResult<Draft, Error, ApproveDraftVars> {
|
|
282
|
+
return useScheduleWrite(api, (v: ApproveDraftVars) => {
|
|
283
|
+
if (typeof v === 'string') return api.approveDraft(v);
|
|
284
|
+
const { id, send_at, timezone } = v;
|
|
285
|
+
return api.approveDraft(id, send_at ? { send_at, timezone } : undefined);
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Drafts and the sends report both change when a schedule does. */
|
|
290
|
+
function useScheduleWrite<R, V>(api: CommsApi, fn: (v: V) => Promise<R>) {
|
|
291
|
+
const qc = useQueryClient();
|
|
292
|
+
return useMutation<R, Error, V>({
|
|
293
|
+
mutationFn: fn,
|
|
294
|
+
onSuccess: () => Promise.all([
|
|
295
|
+
qc.invalidateQueries({ queryKey: commsKeys.draftsAll(api.slug) }),
|
|
296
|
+
qc.invalidateQueries({ queryKey: ['startsimpli', 'comms', api.slug, 'sends'] }),
|
|
297
|
+
]),
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function useRescheduleDraft(api: CommsApi): UseMutationResult<Draft, Error, { id: string } & ScheduleInput> {
|
|
302
|
+
return useScheduleWrite(api, ({ id, ...schedule }: { id: string } & ScheduleInput) => api.rescheduleDraft(id, schedule));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function useRetryDraft(api: CommsApi): UseMutationResult<Draft, Error, string> {
|
|
306
|
+
return useScheduleWrite(api, (id: string) => api.retryDraft(id));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function useCancelDraft(api: CommsApi): UseMutationResult<Draft, Error, string> {
|
|
310
|
+
return useScheduleWrite(api, (id: string) => api.cancelDraft(id));
|
|
279
311
|
}
|
|
280
312
|
|
|
281
313
|
export function useDiscardDraft(api: CommsApi): UseMutationResult<Draft, Error, string> {
|
|
@@ -312,7 +344,7 @@ export function useDeleteAsset(api: CommsApi): UseMutationResult<void, Error, st
|
|
|
312
344
|
// results ------------------------------------------------------------------
|
|
313
345
|
|
|
314
346
|
/** Server-side pagination: page/page_size go to the server, never sliced here. */
|
|
315
|
-
export function useSends(api: CommsApi, q: SendsQuery): UseQueryResult<
|
|
347
|
+
export function useSends(api: CommsApi, q: SendsQuery): UseQueryResult<SendsPage> {
|
|
316
348
|
return useQuery({
|
|
317
349
|
queryKey: commsKeys.sends(api.slug, q),
|
|
318
350
|
queryFn: () => api.listSends(q),
|
package/src/comms/types/index.ts
CHANGED
|
@@ -194,7 +194,32 @@ export interface ChangesResponse {
|
|
|
194
194
|
unclassified: string[];
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
-
export type DraftApproval = 'pending' | 'approved' | 'discarded';
|
|
197
|
+
export type DraftApproval = 'pending' | 'scheduled' | 'expired' | 'failed' | 'approved' | 'discarded';
|
|
198
|
+
|
|
199
|
+
/** The named state of a draft/send (server `state`); every one has a reason and a next action. */
|
|
200
|
+
export type DraftState =
|
|
201
|
+
| 'generating' | 'writing_failed' | 'pending' | 'cancelled' | 'scheduled'
|
|
202
|
+
| 'expired' | 'failed' | 'sending' | 'sent' | 'discarded';
|
|
203
|
+
|
|
204
|
+
export type DraftAction =
|
|
205
|
+
| 'approve' | 'schedule' | 'edit' | 'discard' | 'reschedule' | 'send_now' | 'cancel' | 'retry' | 'wait';
|
|
206
|
+
|
|
207
|
+
/** Why a pending or expired draft needs a person (no recipients, paused at the scheduled time...). */
|
|
208
|
+
export interface DraftNotice {
|
|
209
|
+
code: string;
|
|
210
|
+
reason: string;
|
|
211
|
+
/** The original send time, on an expired draft. */
|
|
212
|
+
send_at?: string | null;
|
|
213
|
+
at?: string;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Why a draft failed, and where: dispatch (before any recipient was queued), queue, or send. */
|
|
217
|
+
export interface DraftFailure {
|
|
218
|
+
stage: 'dispatch' | 'queue' | 'send' | string;
|
|
219
|
+
code: string;
|
|
220
|
+
reason: string;
|
|
221
|
+
at?: string;
|
|
222
|
+
}
|
|
198
223
|
|
|
199
224
|
export interface Draft {
|
|
200
225
|
id: string;
|
|
@@ -216,6 +241,24 @@ export interface Draft {
|
|
|
216
241
|
audience_is_default?: boolean;
|
|
217
242
|
/** Recipients queued, set at approve. */
|
|
218
243
|
recipient_count?: number | null;
|
|
244
|
+
/** UTC instant a scheduled draft goes out (approval 'scheduled'; kept after it starts). */
|
|
245
|
+
send_at?: string | null;
|
|
246
|
+
/** IANA zone the admin typed send_at in; display only, never changes when it sends. */
|
|
247
|
+
send_timezone?: string | null;
|
|
248
|
+
/** Set on a pending draft whose schedule was cancelled. */
|
|
249
|
+
cancelled_at?: string | null;
|
|
250
|
+
state?: DraftState;
|
|
251
|
+
/** What the admin can do next; absent on older servers (treated as the pending set). */
|
|
252
|
+
next_actions?: DraftAction[];
|
|
253
|
+
notice?: DraftNotice | null;
|
|
254
|
+
failure?: DraftFailure | null;
|
|
255
|
+
/** The requested send_at was already past, so it was sent right away. */
|
|
256
|
+
sent_immediately?: boolean;
|
|
257
|
+
requested_send_at?: string | null;
|
|
258
|
+
completed_at?: string | null;
|
|
259
|
+
/** A scheduled send whose audience lookup failed and is being retried. */
|
|
260
|
+
dispatch_attempts?: number;
|
|
261
|
+
dispatch_error?: string | null;
|
|
219
262
|
}
|
|
220
263
|
|
|
221
264
|
/** A tenant-evaluated segment: the key (and params) the foundry's backend understands. */
|
|
@@ -271,6 +314,16 @@ export interface AudienceSegments {
|
|
|
271
314
|
segments: AudienceSegment[];
|
|
272
315
|
}
|
|
273
316
|
|
|
317
|
+
/**
|
|
318
|
+
* When to send an approved draft. `send_at` is ISO 8601: with an offset or Z it
|
|
319
|
+
* is an exact instant; a local time ("2026-11-03T10:00") needs `timezone` (IANA).
|
|
320
|
+
* Stored and dispatched in UTC by the server.
|
|
321
|
+
*/
|
|
322
|
+
export interface ScheduleInput {
|
|
323
|
+
send_at: string;
|
|
324
|
+
timezone?: string;
|
|
325
|
+
}
|
|
326
|
+
|
|
274
327
|
/** POST drafts/: either the email as written (201) or notes to write it from (202, generating). */
|
|
275
328
|
export type DraftCreate =
|
|
276
329
|
| { subject: string; html: string; preheader?: string; text?: string }
|
|
@@ -340,6 +393,22 @@ export interface SendRow {
|
|
|
340
393
|
error: string | null;
|
|
341
394
|
}
|
|
342
395
|
|
|
396
|
+
/** A draft waiting for its send_at, or one whose schedule was cancelled (sends report). */
|
|
397
|
+
export interface ScheduledSend {
|
|
398
|
+
id: string;
|
|
399
|
+
program_key: string;
|
|
400
|
+
subject: string;
|
|
401
|
+
status: 'scheduled' | 'expired' | 'failed' | 'cancelled';
|
|
402
|
+
/** Why: the failure, the pause that expired it, or who cancelled it. */
|
|
403
|
+
reason?: string;
|
|
404
|
+
send_at: string | null;
|
|
405
|
+
send_timezone: string | null;
|
|
406
|
+
cancelled_at: string | null;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** GET sends/: the recipient rows (paginated) plus the not-yet-sent drafts. */
|
|
410
|
+
export type SendsPage = Paginated<SendRow> & { scheduled?: ScheduledSend[] };
|
|
411
|
+
|
|
343
412
|
export interface SendsQuery extends PageParams {
|
|
344
413
|
program?: string;
|
|
345
414
|
status?: string;
|