@startsimpli/integrations 0.2.35 → 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 +2 -0
- package/package.json +23 -6
- package/src/__tests__/ingest.test.ts +127 -0
- package/src/__tests__/safe-fetch-pinning.test.ts +198 -0
- package/src/__tests__/safe-fetch.test.ts +186 -0
- package/src/comms/__tests__/api.test.ts +327 -0
- package/src/comms/__tests__/customer-emails-page.test.tsx +678 -0
- package/src/comms/__tests__/fixtures.ts +133 -0
- package/src/comms/api/_fetch.ts +103 -0
- package/src/comms/api/comms.ts +120 -0
- package/src/comms/api/index.ts +2 -0
- package/src/comms/components/ApprovalModeSetting.tsx +49 -0
- package/src/comms/components/DraftReview.tsx +162 -0
- package/src/comms/components/EmailPreview.tsx +28 -0
- package/src/comms/components/ProductUpdates.tsx +178 -0
- package/src/comms/components/ProgramToggles.tsx +93 -0
- package/src/comms/components/ResultsPanel.tsx +50 -0
- package/src/comms/components/SendLog.tsx +118 -0
- package/src/comms/components/SendingDomain.tsx +120 -0
- package/src/comms/components/SequenceSettings.tsx +280 -0
- package/src/comms/components/VoiceSettings.tsx +84 -0
- package/src/comms/components/_shared.tsx +55 -0
- package/src/comms/components/index.ts +11 -0
- package/src/comms/components/pages/CustomerEmailsPage.tsx +112 -0
- package/src/comms/hooks/index.ts +253 -0
- package/src/comms/index.ts +14 -0
- package/src/comms/types/index.ts +308 -0
- package/src/ingest/index.ts +126 -0
- package/src/safe-fetch/index.ts +387 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React Query hooks over a CommsApi (one per round-trip the page makes).
|
|
3
|
+
*
|
|
4
|
+
* Every hook takes the bound `api` rather than reading a module singleton, so
|
|
5
|
+
* two foundries on one screen never share a cache entry: keys start with
|
|
6
|
+
* ['startsimpli', 'comms', api.slug].
|
|
7
|
+
*
|
|
8
|
+
* Mutations invalidate what they change: program -> programs, settings/domain
|
|
9
|
+
* -> settings, send-now / draft approve / discard / edit -> drafts.
|
|
10
|
+
* Nothing here writes on mount; previews fetch only once `enabled`.
|
|
11
|
+
*/
|
|
12
|
+
import {
|
|
13
|
+
useMutation,
|
|
14
|
+
useQuery,
|
|
15
|
+
useQueryClient,
|
|
16
|
+
type UseMutationResult,
|
|
17
|
+
type UseQueryResult,
|
|
18
|
+
} from '@tanstack/react-query';
|
|
19
|
+
|
|
20
|
+
import type { CommsApi } from '../api/comms';
|
|
21
|
+
import type {
|
|
22
|
+
CommsMetrics,
|
|
23
|
+
CommsProgram,
|
|
24
|
+
CommsSettings,
|
|
25
|
+
CommsSettingsPatch,
|
|
26
|
+
Draft,
|
|
27
|
+
DraftPatch,
|
|
28
|
+
DraftsQuery,
|
|
29
|
+
MetricsQuery,
|
|
30
|
+
Paginated,
|
|
31
|
+
ProgramPatch,
|
|
32
|
+
ProgramUpdateResult,
|
|
33
|
+
RenderedEmail,
|
|
34
|
+
SendNowResult,
|
|
35
|
+
SendRow,
|
|
36
|
+
SendsQuery,
|
|
37
|
+
StepGeneration,
|
|
38
|
+
TestSendInput,
|
|
39
|
+
TestSendResult,
|
|
40
|
+
} from '../types/index';
|
|
41
|
+
|
|
42
|
+
export const commsKeys = {
|
|
43
|
+
all: (slug: string) => ['startsimpli', 'comms', slug] as const,
|
|
44
|
+
settings: (slug: string) => ['startsimpli', 'comms', slug, 'settings'] as const,
|
|
45
|
+
programs: (slug: string) => ['startsimpli', 'comms', slug, 'programs'] as const,
|
|
46
|
+
generation: (slug: string, key: string, step: number) =>
|
|
47
|
+
['startsimpli', 'comms', slug, 'generation', key, step] as const,
|
|
48
|
+
preview: (slug: string, key: string, step: number) =>
|
|
49
|
+
['startsimpli', 'comms', slug, 'preview', key, step] as const,
|
|
50
|
+
draftsAll: (slug: string) => ['startsimpli', 'comms', slug, 'drafts'] as const,
|
|
51
|
+
drafts: (slug: string, q: DraftsQuery = {}) => ['startsimpli', 'comms', slug, 'drafts', q] as const,
|
|
52
|
+
draft: (slug: string, id: string) => ['startsimpli', 'comms', slug, 'drafts', 'one', id] as const,
|
|
53
|
+
draftPreview: (slug: string, id: string) => ['startsimpli', 'comms', slug, 'draft-preview', id] as const,
|
|
54
|
+
sends: (slug: string, q: SendsQuery = {}) => ['startsimpli', 'comms', slug, 'sends', q] as const,
|
|
55
|
+
metrics: (slug: string, q: MetricsQuery = {}) => ['startsimpli', 'comms', slug, 'metrics', q] as const,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** How often the page re-asks about background work (generation). */
|
|
59
|
+
export const DEFAULT_POLL_MS = 2000;
|
|
60
|
+
|
|
61
|
+
// settings ---------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
export function useCommsSettings(api: CommsApi): UseQueryResult<CommsSettings> {
|
|
64
|
+
return useQuery({ queryKey: commsKeys.settings(api.slug), queryFn: () => api.getSettings() });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function useSettingsWrite<V>(api: CommsApi, fn: (v: V) => Promise<CommsSettings>) {
|
|
68
|
+
const qc = useQueryClient();
|
|
69
|
+
return useMutation<CommsSettings, Error, V>({
|
|
70
|
+
mutationFn: fn,
|
|
71
|
+
onSuccess: (data) => {
|
|
72
|
+
qc.setQueryData(commsKeys.settings(api.slug), data);
|
|
73
|
+
return qc.invalidateQueries({ queryKey: commsKeys.settings(api.slug) });
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Invalidates settings. `voice` is replaced whole server-side: send all of it. */
|
|
79
|
+
export function useUpdateCommsSettings(api: CommsApi): UseMutationResult<CommsSettings, Error, CommsSettingsPatch> {
|
|
80
|
+
return useSettingsWrite(api, (patch: CommsSettingsPatch) => api.updateSettings(patch));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function useConnectDomain(api: CommsApi): UseMutationResult<CommsSettings, Error, string> {
|
|
84
|
+
return useSettingsWrite(api, (domain: string) => api.connectDomain(domain));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function useCheckDomain(api: CommsApi): UseMutationResult<CommsSettings, Error, void> {
|
|
88
|
+
return useSettingsWrite(api, () => api.checkDomain());
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// programs ---------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
export function usePrograms(api: CommsApi): UseQueryResult<CommsProgram[]> {
|
|
94
|
+
return useQuery({ queryKey: commsKeys.programs(api.slug), queryFn: () => api.listPrograms() });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function useProgramsWrite<R extends CommsProgram, V>(api: CommsApi, fn: (v: V) => Promise<R>) {
|
|
98
|
+
const qc = useQueryClient();
|
|
99
|
+
return useMutation<R, Error, V>({
|
|
100
|
+
mutationFn: fn,
|
|
101
|
+
onSuccess: (program) => {
|
|
102
|
+
qc.setQueryData<CommsProgram[]>(commsKeys.programs(api.slug), (old) => {
|
|
103
|
+
if (!old) return old;
|
|
104
|
+
// The PATCH response may carry `cancelled`; it is not part of the program.
|
|
105
|
+
const { cancelled: _c, ...clean } = program as ProgramUpdateResult;
|
|
106
|
+
return old.map((p) => (p.key === program.key ? (clean as CommsProgram) : p));
|
|
107
|
+
});
|
|
108
|
+
return qc.invalidateQueries({ queryKey: commsKeys.programs(api.slug) });
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Invalidates programs; on enabled:false the server cancels pending steps (A8). */
|
|
114
|
+
export function useUpdateProgram(
|
|
115
|
+
api: CommsApi,
|
|
116
|
+
): UseMutationResult<ProgramUpdateResult, Error, { key: string; patch: ProgramPatch }> {
|
|
117
|
+
return useProgramsWrite(api, ({ key, patch }: { key: string; patch: ProgramPatch }) =>
|
|
118
|
+
api.updateProgram(key, patch));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Queues generation (202). Follow it with useStepGeneration. */
|
|
122
|
+
export function useGenerateStep(
|
|
123
|
+
api: CommsApi,
|
|
124
|
+
): UseMutationResult<StepGeneration, Error, { key: string; step: number; instructions?: string }> {
|
|
125
|
+
const qc = useQueryClient();
|
|
126
|
+
return useMutation({
|
|
127
|
+
mutationFn: ({ key, step, instructions }) =>
|
|
128
|
+
api.generateStep(key, step, instructions ? { instructions } : {}),
|
|
129
|
+
onSuccess: (status, { key, step }) => {
|
|
130
|
+
qc.setQueryData(commsKeys.generation(api.slug, key, step), status);
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Polls the generation status while `enabled` and it is still pending. When it
|
|
137
|
+
* settles on done, the programs list is refetched so the step shows its draft.
|
|
138
|
+
*/
|
|
139
|
+
export function useStepGeneration(
|
|
140
|
+
api: CommsApi,
|
|
141
|
+
key: string,
|
|
142
|
+
step: number,
|
|
143
|
+
enabled: boolean,
|
|
144
|
+
pollMs: number = DEFAULT_POLL_MS,
|
|
145
|
+
): UseQueryResult<StepGeneration> {
|
|
146
|
+
const qc = useQueryClient();
|
|
147
|
+
return useQuery({
|
|
148
|
+
queryKey: commsKeys.generation(api.slug, key, step),
|
|
149
|
+
queryFn: async () => {
|
|
150
|
+
const status = await api.getStepGeneration(key, step);
|
|
151
|
+
if (status.status === 'done') await qc.invalidateQueries({ queryKey: commsKeys.programs(api.slug) });
|
|
152
|
+
return status;
|
|
153
|
+
},
|
|
154
|
+
enabled,
|
|
155
|
+
refetchInterval: (q) => (q.state.data?.status === 'pending' ? pollMs : false),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function useApproveStep(api: CommsApi): UseMutationResult<CommsProgram, Error, { key: string; step: number }> {
|
|
160
|
+
return useProgramsWrite(api, ({ key, step }: { key: string; step: number }) => api.approveStep(key, step));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Fetches only while `enabled` (i.e. after the admin asks for the preview). */
|
|
164
|
+
export function useStepPreview(api: CommsApi, key: string, step: number, enabled: boolean): UseQueryResult<RenderedEmail> {
|
|
165
|
+
return useQuery({
|
|
166
|
+
queryKey: commsKeys.preview(api.slug, key, step),
|
|
167
|
+
queryFn: () => api.previewStep(key, step),
|
|
168
|
+
enabled,
|
|
169
|
+
staleTime: 0,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function useSendTest(api: CommsApi): UseMutationResult<TestSendResult, Error, TestSendInput> {
|
|
174
|
+
return useMutation({ mutationFn: (input: TestSendInput) => api.sendTest(input) });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// product updates ----------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
/** Re-polls while any listed draft is still being written. */
|
|
180
|
+
export function useDrafts(api: CommsApi, q: DraftsQuery, pollMs: number = DEFAULT_POLL_MS): UseQueryResult<Paginated<Draft>> {
|
|
181
|
+
return useQuery({
|
|
182
|
+
queryKey: commsKeys.drafts(api.slug, q),
|
|
183
|
+
queryFn: () => api.listDrafts(q),
|
|
184
|
+
refetchInterval: (query) =>
|
|
185
|
+
query.state.data?.results.some((d) => d.generating) ? pollMs : false,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** One draft, polled while `generating` (send-now hands back a placeholder). */
|
|
190
|
+
export function useDraft(api: CommsApi, id: string | null, pollMs: number = DEFAULT_POLL_MS): UseQueryResult<Draft> {
|
|
191
|
+
return useQuery({
|
|
192
|
+
queryKey: commsKeys.draft(api.slug, id ?? ''),
|
|
193
|
+
queryFn: () => api.getDraft(id as string),
|
|
194
|
+
enabled: !!id,
|
|
195
|
+
refetchInterval: (query) => (query.state.data && !query.state.data.generating ? false : pollMs),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function useDraftPreview(api: CommsApi, id: string, enabled: boolean): UseQueryResult<RenderedEmail> {
|
|
200
|
+
return useQuery({
|
|
201
|
+
queryKey: commsKeys.draftPreview(api.slug, id),
|
|
202
|
+
queryFn: () => api.previewDraft(id),
|
|
203
|
+
enabled,
|
|
204
|
+
staleTime: 0,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function useDraftsWrite<R, V>(api: CommsApi, fn: (v: V) => Promise<R>) {
|
|
209
|
+
const qc = useQueryClient();
|
|
210
|
+
return useMutation<R, Error, V>({
|
|
211
|
+
mutationFn: fn,
|
|
212
|
+
onSuccess: () => qc.invalidateQueries({ queryKey: commsKeys.draftsAll(api.slug) }),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function useUpdateDraft(api: CommsApi): UseMutationResult<Draft, Error, { id: string; patch: DraftPatch }> {
|
|
217
|
+
const qc = useQueryClient();
|
|
218
|
+
return useMutation({
|
|
219
|
+
mutationFn: ({ id, patch }) => api.updateDraft(id, patch),
|
|
220
|
+
onSuccess: (_d, { id }) => Promise.all([
|
|
221
|
+
qc.invalidateQueries({ queryKey: commsKeys.draftsAll(api.slug) }),
|
|
222
|
+
qc.invalidateQueries({ queryKey: commsKeys.draftPreview(api.slug, id) }),
|
|
223
|
+
]),
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function useApproveDraft(api: CommsApi): UseMutationResult<Draft, Error, string> {
|
|
228
|
+
return useDraftsWrite(api, (id: string) => api.approveDraft(id));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function useDiscardDraft(api: CommsApi): UseMutationResult<Draft, Error, string> {
|
|
232
|
+
return useDraftsWrite(api, (id: string) => api.discardDraft(id));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Invalidates drafts. A 202 result's draft is still generating: follow it with useDraft. */
|
|
236
|
+
export function useSendUpdateNow(api: CommsApi): UseMutationResult<SendNowResult, Error, { notes?: string }> {
|
|
237
|
+
return useDraftsWrite(api, (input: { notes?: string }) => api.sendUpdateNow(input));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// results ------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
/** Server-side pagination: page/page_size go to the server, never sliced here. */
|
|
243
|
+
export function useSends(api: CommsApi, q: SendsQuery): UseQueryResult<Paginated<SendRow>> {
|
|
244
|
+
return useQuery({
|
|
245
|
+
queryKey: commsKeys.sends(api.slug, q),
|
|
246
|
+
queryFn: () => api.listSends(q),
|
|
247
|
+
placeholderData: (prev) => prev,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function useCommsMetrics(api: CommsApi, q: MetricsQuery): UseQueryResult<CommsMetrics> {
|
|
252
|
+
return useQuery({ queryKey: commsKeys.metrics(api.slug, q), queryFn: () => api.getMetrics(q) });
|
|
253
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @startsimpli/integrations/comms: customer email for a foundry (epic startsim-uwiw6,
|
|
3
|
+
* lane startsim-r7hws). Typed client over the central comms admin API,
|
|
4
|
+
* React Query hooks, and the Customer emails page composer that
|
|
5
|
+
* apps/foundry mounts per foundry.
|
|
6
|
+
*
|
|
7
|
+
* Subpath only: NOT re-exported from the package root, so a root import never
|
|
8
|
+
* pulls in @startsimpli/ui, and comms' setApiBase/setAuthTokenProvider never
|
|
9
|
+
* collide with the root's.
|
|
10
|
+
*/
|
|
11
|
+
export * from './types/index';
|
|
12
|
+
export * from './api/index';
|
|
13
|
+
export * from './hooks/index';
|
|
14
|
+
export * from './components/index';
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire types for the foundry comms admin API (bd startsim-bzcrj ADR, section 2).
|
|
3
|
+
*
|
|
4
|
+
* SNAKE_CASE ON PURPOSE. These mirror the DRF serializers field for field, and
|
|
5
|
+
* this package talks to Django through its own `request()` (api/_fetch.ts), which
|
|
6
|
+
* does no key transform. `@startsimpli/api`'s client camelCases responses, which
|
|
7
|
+
* is why the composer is not driven by it: every field here would come back
|
|
8
|
+
* renamed and read as undefined.
|
|
9
|
+
*
|
|
10
|
+
* Routes live under /api/v1/foundry/tenants/{slug}/comms/.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// ---- settings -------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
export type ApprovalMode = 'auto' | 'approve';
|
|
16
|
+
export type DomainStatus = 'none' | 'pending' | 'verified' | 'failed';
|
|
17
|
+
|
|
18
|
+
export interface CommsVoice {
|
|
19
|
+
product: string;
|
|
20
|
+
tone: string;
|
|
21
|
+
audience: string;
|
|
22
|
+
cta_url: string;
|
|
23
|
+
avoid_words: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type DomainRecordPurpose = 'spf' | 'dkim' | 'dmarc';
|
|
27
|
+
|
|
28
|
+
export interface DomainRecord {
|
|
29
|
+
/** e.g. "TXT" / "CNAME". */
|
|
30
|
+
type: string;
|
|
31
|
+
host: string;
|
|
32
|
+
value: string;
|
|
33
|
+
ok: boolean;
|
|
34
|
+
/** Which check this record satisfies (UI gap G7). */
|
|
35
|
+
purpose?: DomainRecordPurpose;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SourceRepo {
|
|
39
|
+
provider: 'github';
|
|
40
|
+
owner: string;
|
|
41
|
+
name: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface CommsSettings {
|
|
45
|
+
approval_mode: ApprovalMode;
|
|
46
|
+
voice: CommsVoice;
|
|
47
|
+
sender_name: string;
|
|
48
|
+
reply_to: string;
|
|
49
|
+
postal_address: string;
|
|
50
|
+
sending_domain: string;
|
|
51
|
+
domain_status: DomainStatus;
|
|
52
|
+
domain_records: DomainRecord[];
|
|
53
|
+
domain_checked_at: string | null;
|
|
54
|
+
paused: boolean;
|
|
55
|
+
source_repos: SourceRepo[];
|
|
56
|
+
/**
|
|
57
|
+
* The From address comms mail actually goes out with (UI gap G3): the
|
|
58
|
+
* foundry's own domain once verified, else the StartSimpli marketing stream.
|
|
59
|
+
* null when the stream has no fallback domain configured. Read-only.
|
|
60
|
+
*/
|
|
61
|
+
from_address: string | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* PATCH body. Domain fields and from_address are read-only server-side.
|
|
66
|
+
* `voice` is a JSON field the server REPLACES, so send the whole object.
|
|
67
|
+
*/
|
|
68
|
+
export type CommsSettingsPatch = Partial<
|
|
69
|
+
Omit<CommsSettings, 'sending_domain' | 'domain_status' | 'domain_records' | 'domain_checked_at' | 'from_address'>
|
|
70
|
+
>;
|
|
71
|
+
|
|
72
|
+
// ---- programs -------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
export type ProgramKind = 'sequence' | 'broadcast';
|
|
75
|
+
export type ProgramCategory = 'lifecycle' | 'product_updates';
|
|
76
|
+
export type ContentStatus = 'empty' | 'draft' | 'approved';
|
|
77
|
+
|
|
78
|
+
export interface EmailContent {
|
|
79
|
+
subject: string;
|
|
80
|
+
preheader: string;
|
|
81
|
+
html: string;
|
|
82
|
+
text: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface SequenceStep {
|
|
86
|
+
offset_days: number;
|
|
87
|
+
intent: string;
|
|
88
|
+
content: EmailContent | null;
|
|
89
|
+
content_status: ContentStatus;
|
|
90
|
+
generated_at: string | null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type SequenceTrigger =
|
|
94
|
+
| { type: 'inactive'; days: number }
|
|
95
|
+
| { type: 'joined' }
|
|
96
|
+
| { type: 'event'; name: string };
|
|
97
|
+
|
|
98
|
+
export interface SequenceConfig {
|
|
99
|
+
trigger: SequenceTrigger;
|
|
100
|
+
steps: SequenceStep[];
|
|
101
|
+
exit: string[];
|
|
102
|
+
cooldown_days: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export type BroadcastCadence = 'on_deploy' | 'weekly' | 'monthly' | 'manual';
|
|
106
|
+
export type BroadcastAudience = 'all' | 'active' | 'inactive';
|
|
107
|
+
|
|
108
|
+
export interface BroadcastConfig {
|
|
109
|
+
cadence: BroadcastCadence;
|
|
110
|
+
audience: BroadcastAudience;
|
|
111
|
+
active_days: number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface ProgramBase {
|
|
115
|
+
key: string;
|
|
116
|
+
category: ProgramCategory;
|
|
117
|
+
enabled: boolean;
|
|
118
|
+
version: number;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface SequenceProgram extends ProgramBase {
|
|
122
|
+
kind: 'sequence';
|
|
123
|
+
config: SequenceConfig;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface BroadcastProgram extends ProgramBase {
|
|
127
|
+
kind: 'broadcast';
|
|
128
|
+
config: BroadcastConfig;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export type CommsProgram = SequenceProgram | BroadcastProgram;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* PATCH body. `config` REPLACES the stored config (a sequence config is
|
|
135
|
+
* validated whole: trigger + steps are required), so send the full object.
|
|
136
|
+
*/
|
|
137
|
+
export interface ProgramPatch {
|
|
138
|
+
enabled?: boolean;
|
|
139
|
+
config?: SequenceConfig | BroadcastConfig;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** PATCH response: on enabled:false the server reports how many pending
|
|
143
|
+
* enrollments it cancelled (UI gap G5). */
|
|
144
|
+
export type ProgramUpdateResult = CommsProgram & { cancelled?: number };
|
|
145
|
+
|
|
146
|
+
/** Background step generation (POST 202 / GET poll on steps/{i}/generate/). */
|
|
147
|
+
export type GenerationState = 'idle' | 'pending' | 'done' | 'failed';
|
|
148
|
+
|
|
149
|
+
export interface StepGeneration {
|
|
150
|
+
step_index: number;
|
|
151
|
+
status: GenerationState;
|
|
152
|
+
error: string;
|
|
153
|
+
requested_at: string | null;
|
|
154
|
+
finished_at: string | null;
|
|
155
|
+
step: SequenceStep | null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The two switches persona A sees (A1). Presets are data server-side. */
|
|
159
|
+
export const COMEBACK_PROGRAM_KEY = 'comeback';
|
|
160
|
+
export const PRODUCT_UPDATE_PROGRAM_KEY = 'product_update';
|
|
161
|
+
|
|
162
|
+
// ---- preview / test send ----------------------------------------------------
|
|
163
|
+
|
|
164
|
+
/** Server-rendered for the requesting admin as the sample recipient. */
|
|
165
|
+
export type RenderedEmail = EmailContent;
|
|
166
|
+
|
|
167
|
+
export type TestSendInput =
|
|
168
|
+
| { program_key: string; step_index: number }
|
|
169
|
+
| { draft_id: string };
|
|
170
|
+
|
|
171
|
+
export interface TestSendResult {
|
|
172
|
+
sent: boolean;
|
|
173
|
+
reason?: string;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---- changes / drafts ------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
export interface ChangeItem {
|
|
179
|
+
key: string;
|
|
180
|
+
kind: 'pr' | 'deploy' | 'note';
|
|
181
|
+
title: string;
|
|
182
|
+
body: string;
|
|
183
|
+
url: string;
|
|
184
|
+
merged_at: string | null;
|
|
185
|
+
audience: 'customer' | 'internal';
|
|
186
|
+
feature_group: string;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export interface ChangesResponse {
|
|
190
|
+
items: ChangeItem[];
|
|
191
|
+
window: [string, string];
|
|
192
|
+
/** Keys still waiting on a background classification; re-fetch later. */
|
|
193
|
+
unclassified: string[];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export type DraftApproval = 'pending' | 'approved' | 'discarded';
|
|
197
|
+
|
|
198
|
+
export interface Draft {
|
|
199
|
+
id: string;
|
|
200
|
+
program_key: string;
|
|
201
|
+
subject: string;
|
|
202
|
+
preheader: string;
|
|
203
|
+
html: string;
|
|
204
|
+
text: string;
|
|
205
|
+
approval: DraftApproval;
|
|
206
|
+
item_keys: string[];
|
|
207
|
+
window: [string, string];
|
|
208
|
+
created_at: string;
|
|
209
|
+
/** Set when generation failed (or "nothing_customer_facing" on a discarded draft). */
|
|
210
|
+
error: string | null;
|
|
211
|
+
/** True while a worker is still writing the content: poll getDraft(id). */
|
|
212
|
+
generating: boolean;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export type DraftPatch = Partial<Pick<Draft, 'subject' | 'preheader' | 'html' | 'text'>>;
|
|
216
|
+
|
|
217
|
+
export const NOTHING_CUSTOMER_FACING = 'nothing_customer_facing';
|
|
218
|
+
export const DRAFT_PENDING = 'draft_pending';
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* 202 {draft (generating), reason: null}: poll getDraft(draft.id).
|
|
222
|
+
* 200 {draft: null, reason: nothing_customer_facing}.
|
|
223
|
+
* 200 {draft: <the pending one>, reason: draft_pending}.
|
|
224
|
+
*/
|
|
225
|
+
export interface SendNowResult {
|
|
226
|
+
draft: Draft | null;
|
|
227
|
+
reason?: typeof NOTHING_CUSTOMER_FACING | typeof DRAFT_PENDING | string | null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---- sends / metrics -------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
/** DRF PageNumberPagination envelope ({count, next, previous, results}). */
|
|
233
|
+
export interface Paginated<T> {
|
|
234
|
+
count: number;
|
|
235
|
+
next: string | null;
|
|
236
|
+
previous: string | null;
|
|
237
|
+
results: T[];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export interface PageParams {
|
|
241
|
+
page?: number;
|
|
242
|
+
page_size?: number;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export type SendStatus =
|
|
246
|
+
| 'pending' | 'sent' | 'delivered' | 'opened' | 'clicked'
|
|
247
|
+
| 'bounced' | 'failed' | 'suppressed' | 'complained';
|
|
248
|
+
|
|
249
|
+
export interface SendRow {
|
|
250
|
+
id: string;
|
|
251
|
+
email: string;
|
|
252
|
+
program_key: string;
|
|
253
|
+
step_index: number | null;
|
|
254
|
+
subject: string;
|
|
255
|
+
status: SendStatus | string;
|
|
256
|
+
sent_at: string | null;
|
|
257
|
+
opened_at: string | null;
|
|
258
|
+
clicked_at: string | null;
|
|
259
|
+
error: string | null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export interface SendsQuery extends PageParams {
|
|
263
|
+
program?: string;
|
|
264
|
+
status?: string;
|
|
265
|
+
/** One product-update campaign (a draft id). */
|
|
266
|
+
campaign?: string;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export interface DraftsQuery extends PageParams {
|
|
270
|
+
status?: DraftApproval;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export interface CommsMetrics {
|
|
274
|
+
sent: number;
|
|
275
|
+
delivered: number;
|
|
276
|
+
opened: number;
|
|
277
|
+
clicked: number;
|
|
278
|
+
bounced: number;
|
|
279
|
+
complained: number;
|
|
280
|
+
unsubscribed: number;
|
|
281
|
+
came_back: number;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export interface MetricsQuery {
|
|
285
|
+
program?: string;
|
|
286
|
+
campaign?: string;
|
|
287
|
+
from?: string;
|
|
288
|
+
to?: string;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ---- errors ----------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
export interface CommsErrorPayload {
|
|
294
|
+
error?: string;
|
|
295
|
+
detail?: string;
|
|
296
|
+
[k: string]: unknown;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export class CommsApiError extends Error {
|
|
300
|
+
status: number;
|
|
301
|
+
payload: CommsErrorPayload;
|
|
302
|
+
constructor(message: string, status: number, payload: CommsErrorPayload = {}) {
|
|
303
|
+
super(message);
|
|
304
|
+
this.name = 'CommsApiError';
|
|
305
|
+
this.status = status;
|
|
306
|
+
this.payload = payload;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Document ingest helpers (crochet_patterns-llmj, design D1/PK2): turn a fetched
|
|
3
|
+
* web page or a stored PDF into plain text for a model.
|
|
4
|
+
*
|
|
5
|
+
* SERVER-ONLY. Exported from the `@startsimpli/integrations/ingest` subpath,
|
|
6
|
+
* never from the package root (linkedom and unpdf are node-side dependencies).
|
|
7
|
+
*/
|
|
8
|
+
import { Readability } from '@mozilla/readability';
|
|
9
|
+
import { parseHTML } from 'linkedom';
|
|
10
|
+
import { extractText, getDocumentProxy } from 'unpdf';
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// HTML
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'SVG', 'IFRAME', 'HEAD']);
|
|
17
|
+
const BLOCK = new Set([
|
|
18
|
+
'ADDRESS', 'ARTICLE', 'ASIDE', 'BLOCKQUOTE', 'BODY', 'CAPTION', 'DD', 'DETAILS', 'DIV', 'DL', 'DT',
|
|
19
|
+
'FIELDSET', 'FIGCAPTION', 'FIGURE', 'FOOTER', 'FORM', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HEADER',
|
|
20
|
+
'HR', 'LI', 'MAIN', 'NAV', 'OL', 'P', 'PRE', 'SECTION', 'SUMMARY', 'TABLE', 'TBODY', 'TD', 'TFOOT',
|
|
21
|
+
'TH', 'THEAD', 'TR', 'UL',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
interface DomNode {
|
|
25
|
+
nodeType: number;
|
|
26
|
+
nodeName: string;
|
|
27
|
+
textContent: string | null;
|
|
28
|
+
childNodes: ArrayLike<DomNode>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Text of a DOM subtree with a line break around every block element and at `<br>`. */
|
|
32
|
+
function blockText(root: DomNode): string {
|
|
33
|
+
let out = '';
|
|
34
|
+
const walk = (node: DomNode) => {
|
|
35
|
+
if (node.nodeType === 3) {
|
|
36
|
+
out += (node.textContent ?? '').replace(/\s+/g, ' ');
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (node.nodeType !== 1 && node.nodeType !== 9 && node.nodeType !== 11) return;
|
|
40
|
+
const tag = node.nodeName.toUpperCase();
|
|
41
|
+
if (SKIP.has(tag)) return;
|
|
42
|
+
if (tag === 'BR') {
|
|
43
|
+
out += '\n';
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const block = BLOCK.has(tag);
|
|
47
|
+
if (block) out += '\n';
|
|
48
|
+
for (let i = 0; i < node.childNodes.length; i++) walk(node.childNodes[i]!);
|
|
49
|
+
if (block) out += '\n';
|
|
50
|
+
};
|
|
51
|
+
walk(root);
|
|
52
|
+
return out
|
|
53
|
+
.split('\n')
|
|
54
|
+
.map((line) => line.replace(/[ \t ]+/g, ' ').trim())
|
|
55
|
+
.join('\n')
|
|
56
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
57
|
+
.trim();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The readable text of an HTML page: Readability's main article when it finds
|
|
62
|
+
* one, else the whole body (scripts and styles dropped). Each block element and
|
|
63
|
+
* `<br>` starts a new line, so "Rnd 1 … / Rnd 2 …" list items stay one per line.
|
|
64
|
+
* `baseUrl` lets Readability resolve relative links; it does not change the text.
|
|
65
|
+
*/
|
|
66
|
+
export function readableText(html: string, baseUrl?: string): { title?: string; text: string } {
|
|
67
|
+
// linkedom leaves a bare fragment out of <body>; give it a document to live in.
|
|
68
|
+
const whole = /<html[\s>]/i.test(html) ? html : `<!doctype html><html><head></head><body>${html}</body></html>`;
|
|
69
|
+
const { document } = parseHTML(whole);
|
|
70
|
+
if (baseUrl && !document.querySelector('base')) {
|
|
71
|
+
const base = document.createElement('base');
|
|
72
|
+
base.setAttribute('href', baseUrl);
|
|
73
|
+
document.head?.appendChild(base);
|
|
74
|
+
}
|
|
75
|
+
const docTitle = document.title?.trim() || undefined;
|
|
76
|
+
|
|
77
|
+
let text = '';
|
|
78
|
+
let title = docTitle;
|
|
79
|
+
try {
|
|
80
|
+
// Readability mutates the document it is given; parse a fresh copy.
|
|
81
|
+
const article = new Readability(parseHTML(document.toString()).document as unknown as Document).parse();
|
|
82
|
+
if (article?.content) {
|
|
83
|
+
text = blockText(parseHTML(`<html><body>${article.content}</body></html>`).document as unknown as DomNode);
|
|
84
|
+
title = article.title?.trim() || docTitle;
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
// fall through to the whole body
|
|
88
|
+
}
|
|
89
|
+
if (!text) text = blockText((document.body ?? document) as unknown as DomNode);
|
|
90
|
+
return title ? { title, text } : { text };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// PDF
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
export interface PdfText {
|
|
98
|
+
/** One entry per page, trimmed. */
|
|
99
|
+
pages: string[];
|
|
100
|
+
/** Characters across all pages (trimmed). */
|
|
101
|
+
totalChars: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Text layer of a PDF, one string per page (unpdf / pdf.js). Throws on bytes
|
|
106
|
+
* that are not a readable PDF. The input is copied first, so the caller's
|
|
107
|
+
* buffer (the stored original) is never detached.
|
|
108
|
+
*/
|
|
109
|
+
export async function extractPdfText(bytes: Uint8Array): Promise<PdfText> {
|
|
110
|
+
const pdf = await getDocumentProxy(new Uint8Array(bytes), { verbosity: 0 });
|
|
111
|
+
try {
|
|
112
|
+
const { text } = await extractText(pdf, { mergePages: false });
|
|
113
|
+
const pages = text.map((t) => t.trim());
|
|
114
|
+
return { pages, totalChars: pages.reduce((n, p) => n + p.length, 0) };
|
|
115
|
+
} finally {
|
|
116
|
+
await pdf.loadingTask.destroy().catch(() => undefined);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Average under this many characters per page = no usable text layer (design R6). */
|
|
121
|
+
export const SCANNED_CHARS_PER_PAGE = 200;
|
|
122
|
+
|
|
123
|
+
/** True when a PDF has no usable text layer and should go to the model as a document. */
|
|
124
|
+
export function looksScanned(r: PdfText): boolean {
|
|
125
|
+
return r.pages.length === 0 || r.totalChars / r.pages.length < SCANNED_CHARS_PER_PAGE;
|
|
126
|
+
}
|