ab-ecosystem-cpass 1.0.5 → 1.0.7

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.
Files changed (42) hide show
  1. package/README.md +62 -1
  2. package/dist/client.d.ts +24 -0
  3. package/dist/client.js +32 -5
  4. package/dist/client.js.map +1 -1
  5. package/dist/index.d.ts +9 -0
  6. package/dist/index.js +20 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist/internal/bytes.d.ts +38 -0
  9. package/dist/internal/bytes.js +84 -0
  10. package/dist/internal/bytes.js.map +1 -0
  11. package/dist/internal/device.d.ts +24 -0
  12. package/dist/internal/device.js +91 -0
  13. package/dist/internal/device.js.map +1 -0
  14. package/dist/internal/md5.d.ts +4 -0
  15. package/dist/internal/md5.js +109 -0
  16. package/dist/internal/md5.js.map +1 -0
  17. package/dist/internal/receiver-file.d.ts +106 -0
  18. package/dist/internal/receiver-file.js +128 -0
  19. package/dist/internal/receiver-file.js.map +1 -0
  20. package/dist/resources/audience/notifications.d.ts +171 -0
  21. package/dist/resources/audience/notifications.js +215 -0
  22. package/dist/resources/audience/notifications.js.map +1 -0
  23. package/dist/resources/campaign-audience.d.ts +132 -0
  24. package/dist/resources/campaign-audience.js +224 -0
  25. package/dist/resources/campaign-audience.js.map +1 -0
  26. package/dist/resources/campaign-messages.d.ts +150 -0
  27. package/dist/resources/campaign-messages.js +172 -0
  28. package/dist/resources/campaign-messages.js.map +1 -0
  29. package/dist/resources/campaigns.d.ts +239 -0
  30. package/dist/resources/campaigns.js +300 -0
  31. package/dist/resources/campaigns.js.map +1 -0
  32. package/dist/types/audience.d.ts +82 -0
  33. package/dist/types/campaign.d.ts +319 -0
  34. package/dist/types/campaign.js +3 -0
  35. package/dist/types/campaign.js.map +1 -0
  36. package/dist/types/index.d.ts +1 -0
  37. package/dist/types/index.js +2 -0
  38. package/dist/types/index.js.map +1 -1
  39. package/docs/audience/notifications.md +263 -0
  40. package/docs/campaign/campaign-messages.md +273 -0
  41. package/docs/campaign/campaigns.md +449 -0
  42. package/package.json +1 -1
@@ -0,0 +1,300 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Campaigns = void 0;
4
+ const errors_1 = require("../errors");
5
+ const inbox_media_1 = require("./audience/inbox-media");
6
+ const receiver_file_1 = require("../internal/receiver-file");
7
+ const campaign_audience_1 = require("./campaign-audience");
8
+ /** The dispatch route a file-backed audience is sent under. */
9
+ const BULK_TYPE = 'bulk_campaign';
10
+ /**
11
+ * The dispatch route a bucketed audience is stored under.
12
+ *
13
+ * Stored as `group_campaign`; the campaign service relabels it to
14
+ * `bulk_campaign` on the hand-off to the sending service, because the two differ
15
+ * only in how many receiver files there are. Our own records keep the real name.
16
+ */
17
+ const GROUP_TYPE = 'group_campaign';
18
+ /** Buckets uploaded at once. Bounded so fifty buckets is not fifty open sockets. */
19
+ const UPLOAD_CONCURRENCY = 3;
20
+ class Campaigns {
21
+ http;
22
+ media;
23
+ constructor(http, media) {
24
+ this.http = http;
25
+ // Defaults to the audience service's own presigned-upload endpoint, which is
26
+ // the same path chat media takes.
27
+ this.media = media ?? new inbox_media_1.InboxMedia(http);
28
+ }
29
+ list(query = {}) {
30
+ return this.http.request('GET', '/v1/campaigns', { query });
31
+ }
32
+ get(id) {
33
+ return this.http.request('GET', `/v1/campaigns/${encodeURIComponent(id)}`);
34
+ }
35
+ /**
36
+ * Creates a campaign from a payload you have already assembled.
37
+ *
38
+ * For a bulk recipient list, prefer {@link createBulk} -- it does the
39
+ * encrypting and uploading that a file-backed audience requires, which is not
40
+ * something a caller should have to reproduce.
41
+ */
42
+ create(input) {
43
+ return this.http.request('POST', '/v1/campaigns', { body: input });
44
+ }
45
+ /**
46
+ * Creates a campaign addressed to a list of numbers you supply directly.
47
+ *
48
+ * This is the "bulk campaign" path, and the SDK does the whole of it: the
49
+ * recipient list never travels as part of the campaign, because a million
50
+ * numbers is not a request body. Instead it is encrypted into a file, uploaded
51
+ * to storage, and the campaign stores a pointer to it -- which the sending
52
+ * service fetches and decrypts when the campaign runs.
53
+ *
54
+ * ```ts
55
+ * // Pasted numbers, no variables
56
+ * const { campaign, receivers } = await crm.campaigns.createBulk({
57
+ * name: 'Diwali offer',
58
+ * audience: { numbers: '919000000001, 919000000002\n919000000003' },
59
+ * messages: [{ channel: { id: channelId }, message: template }],
60
+ * });
61
+ *
62
+ * // A CSV, with every column available as a template variable
63
+ * await crm.campaigns.createBulk({
64
+ * name: 'Renewal reminder',
65
+ * audience: { csv: await file.text(), recipientColumn: 'phone' },
66
+ * messages: [{ channel: { id: channelId }, message: template }],
67
+ * trigger: 'schedule',
68
+ * scheduledAt: '2026-11-01T04:30:00.000Z',
69
+ * });
70
+ * ```
71
+ *
72
+ * ## What it does, in order
73
+ *
74
+ * 1. **Resolves the audience.** Numbers are reduced to digits; CSV or TSV text
75
+ * is parsed with quoted fields honoured; every column becomes a per-recipient
76
+ * variable. Rows with no usable number are skipped and duplicates dropped,
77
+ * both counted in the result rather than passed over in silence.
78
+ * 2. **Encrypts the list.** AES-256-CBC under a secret generated fresh for this
79
+ * one file. See `internal/receiver-file.ts` for the exact scheme.
80
+ * 3. **Uploads it** as a `document` through the media endpoint: a presigned URL
81
+ * is issued and the bytes go straight to storage, so the file never passes
82
+ * through the API.
83
+ * 4. **Creates the campaign** with `type: 'bulk_campaign'` and
84
+ * `filters: [{numbers: <url>, hash: <secret>}]`.
85
+ *
86
+ * ## Two things worth knowing
87
+ *
88
+ * **The returned `campaign.receivers` is `0`, and that is correct.** The
89
+ * audience is resolved when the campaign is sent, not when it is created. Use
90
+ * the `receivers` on this result -- the number actually written into the file --
91
+ * for anything you show the user now.
92
+ *
93
+ * **The file is readable by anyone holding both the URL and the `hash`.** The
94
+ * URL is a public storage link, so the secret stored alongside it on the
95
+ * campaign is the only thing protecting the list. It is a credential: do not log
96
+ * it, and do not copy a `filters` entry from one campaign into another system.
97
+ *
98
+ * Throws `validation_failed` if the audience resolves to no recipients, or to
99
+ * more than a million.
100
+ */
101
+ async createBulk(input) {
102
+ const { audience, signal, ...campaign } = input;
103
+ const resolved = (0, campaign_audience_1.resolveAudience)(audience);
104
+ if (!resolved.receivers.length) {
105
+ throw new errors_1.CrmApiError(400, 'validation_failed', resolved.skipped > 0
106
+ ? `None of the ${resolved.skipped} entries supplied held a usable number`
107
+ : 'The audience resolved to no recipients');
108
+ }
109
+ if (resolved.receivers.length > campaign_audience_1.MAX_BULK_RECEIVERS) {
110
+ throw new errors_1.CrmApiError(400, 'validation_failed', `A bulk campaign may address at most ${campaign_audience_1.MAX_BULK_RECEIVERS.toLocaleString('en-US')} recipients; ` +
111
+ `this audience has ${resolved.receivers.length.toLocaleString('en-US')}. Split it across campaigns.`);
112
+ }
113
+ const filter = await this.uploadReceivers(resolved.receivers, signal);
114
+ const created = await this.create({
115
+ ...campaign,
116
+ type: input.type ?? BULK_TYPE,
117
+ filters: [filter],
118
+ });
119
+ return {
120
+ campaign: created,
121
+ receivers: resolved.receivers.length,
122
+ skipped: resolved.skipped,
123
+ duplicates: resolved.duplicates,
124
+ fileUrl: filter.numbers,
125
+ };
126
+ }
127
+ /**
128
+ * Creates a campaign whose recipients are split into buckets, each sent through
129
+ * a different channel or rendered with a different message.
130
+ *
131
+ * ```ts
132
+ * const { campaign, buckets, receivers } = await crm.campaigns.createGroup({
133
+ * name: 'Diwali offer',
134
+ * distributeBy: 'channel',
135
+ * buckets: [
136
+ * { channel: channelA, audience: { rows: north, recipientColumn: 'phone' } },
137
+ * { channel: channelB, audience: { rows: south, recipientColumn: 'phone' } },
138
+ * ],
139
+ * messages: [{ channel: { id: channelA }, message: template }],
140
+ * });
141
+ * ```
142
+ *
143
+ * ## How this differs from `createBulk`
144
+ *
145
+ * Three ways, and each of them is a thing that would be wrong if you built the
146
+ * payload by hand:
147
+ *
148
+ * - **One file per bucket, not one per campaign.** Each bucket is encrypted
149
+ * under its own secret and uploaded separately, and `filters` carries one
150
+ * entry per bucket. This is the only audience mode where `filters` has more
151
+ * than one element.
152
+ * - **A different file format.** A bucket file is a `||`-delimited table with a
153
+ * header row, not the JSON array a bulk file holds. The reading side chooses
154
+ * by campaign type, so the wrong encoding decrypts cleanly and then parses to
155
+ * nothing.
156
+ * - **Columns are fixed by the first recipient.** A table has one header, so
157
+ * every row carries the same variables in the same order. A bucket whose rows
158
+ * have differing columns will have the missing ones filled in as empty.
159
+ *
160
+ * Everything else is the same: numbers are reduced to digits, CSV is parsed
161
+ * with quoted fields honoured, duplicates and unusable rows are dropped and
162
+ * counted -- per bucket, and summed on the result.
163
+ *
164
+ * ## Empty buckets are dropped, not refused
165
+ *
166
+ * A bucket whose audience resolves to nobody is left out of `filters`
167
+ * altogether, matching the panel: distributing a list across six channels
168
+ * routinely leaves one or two with nothing in them, and that is not an error.
169
+ * The call fails only when **every** bucket is empty.
170
+ *
171
+ * De-duplication is per bucket. A number appearing in two buckets is sent to
172
+ * twice -- which is usually the intent, since the buckets are different
173
+ * channels, and is not something this can second-guess.
174
+ */
175
+ async createGroup(input) {
176
+ const { buckets, distributeBy, signal, ...campaign } = input;
177
+ const resolved = buckets.map((bucket) => ({ bucket, audience: (0, campaign_audience_1.resolveAudience)(bucket.audience) }));
178
+ const usable = resolved.filter((entry) => entry.audience.receivers.length > 0);
179
+ const totals = resolved.reduce((sum, entry) => ({
180
+ receivers: sum.receivers + entry.audience.receivers.length,
181
+ skipped: sum.skipped + entry.audience.skipped,
182
+ duplicates: sum.duplicates + entry.audience.duplicates,
183
+ }), { receivers: 0, skipped: 0, duplicates: 0 });
184
+ if (!usable.length) {
185
+ throw new errors_1.CrmApiError(400, 'validation_failed', buckets.length
186
+ ? `None of the ${buckets.length} buckets resolved to a recipient`
187
+ : 'A group campaign needs at least one bucket');
188
+ }
189
+ if (totals.receivers > campaign_audience_1.MAX_BULK_RECEIVERS) {
190
+ throw new errors_1.CrmApiError(400, 'validation_failed', `A campaign may address at most ${campaign_audience_1.MAX_BULK_RECEIVERS.toLocaleString('en-US')} recipients across all ` +
191
+ `buckets; this one has ${totals.receivers.toLocaleString('en-US')}. Split it across campaigns.`);
192
+ }
193
+ const uploaded = await mapWithLimit(usable, UPLOAD_CONCURRENCY, async ({ bucket, audience }) => {
194
+ const filter = await this.uploadReceivers(audience.receivers, signal, 'table');
195
+ return {
196
+ filter: {
197
+ ...bucket.extra,
198
+ type: distributeBy,
199
+ ...(bucket.channel === undefined ? {} : { channel: bucket.channel }),
200
+ ...(bucket.pairIndex === undefined ? {} : { pairIndex: bucket.pairIndex }),
201
+ // Carried because the panel carried it: the manager can read a bucket's
202
+ // size without downloading and decrypting the file to count it.
203
+ count: audience.receivers.length,
204
+ ...filter,
205
+ },
206
+ result: {
207
+ receivers: audience.receivers.length,
208
+ skipped: audience.skipped,
209
+ duplicates: audience.duplicates,
210
+ fileUrl: filter.numbers,
211
+ ...(bucket.channel === undefined ? {} : { channel: bucket.channel }),
212
+ ...(bucket.pairIndex === undefined ? {} : { pairIndex: bucket.pairIndex }),
213
+ },
214
+ };
215
+ });
216
+ const created = await this.create({
217
+ ...campaign,
218
+ type: input.type ?? GROUP_TYPE,
219
+ filters: uploaded.map((entry) => entry.filter),
220
+ });
221
+ return { campaign: created, buckets: uploaded.map((entry) => entry.result), ...totals };
222
+ }
223
+ /**
224
+ * Encrypts and uploads a receiver list, answering with the audience filter it
225
+ * produced.
226
+ *
227
+ * The file half of {@link createBulk}, exposed because the two halves can fail
228
+ * for unrelated reasons. Holding the filter lets you retry a failed campaign
229
+ * creation -- or create several campaigns against one uploaded list -- without
230
+ * re-uploading, which matters when the list is large.
231
+ *
232
+ * The returned `hash` is the decryption secret. Keep it with the URL or the
233
+ * file becomes unreadable; there is no copy of it anywhere else.
234
+ */
235
+ async uploadReceivers(receivers, signal,
236
+ /**
237
+ * How the list is encoded inside the file. `json` for a bulk campaign's
238
+ * single file, `table` for a group campaign's bucket. The reading side picks
239
+ * by campaign type, so this has to match the campaign it is for.
240
+ */
241
+ encoding = 'json') {
242
+ const file = await (0, receiver_file_1.buildReceiverFile)([...receivers], encoding);
243
+ const source = {
244
+ body: file.body,
245
+ filename: file.filename,
246
+ contentType: file.contentType,
247
+ size: file.size,
248
+ };
249
+ // `document` rather than a kind of its own: it is the one kind that accepts
250
+ // an arbitrary media type, and at 100MB its ceiling is far above a million
251
+ // encrypted numbers.
252
+ const numbers = await this.media.upload(source, {
253
+ kind: 'document',
254
+ filename: file.filename,
255
+ contentType: file.contentType,
256
+ signal,
257
+ });
258
+ return { numbers, hash: file.secret };
259
+ }
260
+ update(id, input) {
261
+ return this.http.request('PATCH', `/v1/campaigns/${encodeURIComponent(id)}`, { body: input });
262
+ }
263
+ schedule(id, scheduledAt) {
264
+ return this.http.request('POST', `/v1/campaigns/${encodeURIComponent(id)}/schedule`, {
265
+ body: { scheduledAt },
266
+ });
267
+ }
268
+ cancel(id) {
269
+ return this.http.request('POST', `/v1/campaigns/${encodeURIComponent(id)}/cancel`);
270
+ }
271
+ delete(id) {
272
+ return this.http.request('DELETE', `/v1/campaigns/${encodeURIComponent(id)}`);
273
+ }
274
+ }
275
+ exports.Campaigns = Campaigns;
276
+ /**
277
+ * `Promise.all` with a ceiling.
278
+ *
279
+ * A group campaign can have fifty buckets, and the panel uploaded them with an
280
+ * unbounded `Promise.all` -- fifty simultaneous uploads, which a browser queues
281
+ * anyway and a flaky connection turns into fifty retries at once. Three at a
282
+ * time finishes a realistic campaign in about the same wall-clock time without
283
+ * that.
284
+ *
285
+ * Results stay in input order, which `filters` depends on: a `template` bucket
286
+ * is addressed by its `pairIndex`, and reordering would silently re-point them.
287
+ */
288
+ const mapWithLimit = async (items, limit, run) => {
289
+ const results = new Array(items.length);
290
+ let next = 0;
291
+ const worker = async () => {
292
+ while (next < items.length) {
293
+ const index = next++;
294
+ results[index] = await run(items[index]);
295
+ }
296
+ };
297
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
298
+ return results;
299
+ };
300
+ //# sourceMappingURL=campaigns.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"campaigns.js","sourceRoot":"","sources":["../../src/resources/campaigns.ts"],"names":[],"mappings":"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"}
@@ -520,3 +520,85 @@ export interface WebhookEnvelope {
520
520
  createdAt: IsoDate;
521
521
  data: unknown;
522
522
  }
523
+ /**
524
+ * Where a push notification can be delivered.
525
+ *
526
+ * It decides which half of the FCM payload the send path fills in -- `webpush`
527
+ * for a browser, `android`/`apns` for an app -- and it is the one thing about a
528
+ * device that cannot be inferred from its token. A registration token is an
529
+ * opaque string identifying a browser or app *instance*; nothing in it says what
530
+ * kind of thing that instance is.
531
+ */
532
+ export type DevicePlatform = 'web' | 'android' | 'ios';
533
+ /**
534
+ * A subscribed device, as it reads back.
535
+ *
536
+ * The registration token is **never** returned. It is a send credential --
537
+ * anything holding one can push to that browser through the agency's Firebase
538
+ * project -- and the list exists so a user can recognise and revoke their own
539
+ * devices, which needs the label and the last-seen time and nothing more.
540
+ */
541
+ export interface Device {
542
+ id: ObjectId;
543
+ platform: DevicePlatform;
544
+ /** Whatever `device_label` was sent on registration; `''` when none was. */
545
+ label: string;
546
+ /** The reseller frontend it registered from; `null` for the white-label one. */
547
+ clientId: string | null;
548
+ /**
549
+ * Last time a notification to this device succeeded, or it re-subscribed.
550
+ *
551
+ * The only signal that a device is still real: FCM does not revoke a token
552
+ * when an app is uninstalled or a browser's site data is cleared, it simply
553
+ * stops delivering to it. A device unseen for 60 days is pruned on that basis,
554
+ * which is another reason to call {@link Notifications.subscribe} on every app
555
+ * load rather than once.
556
+ */
557
+ lastSeenAt: IsoDate;
558
+ createdAt: IsoDate;
559
+ }
560
+ /**
561
+ * What `notifications.subscribe()` takes. Mirrors the wire body field for field.
562
+ *
563
+ * There is deliberately no user field. The acting user comes from the verified
564
+ * token the client was constructed with; a body that could name its own user
565
+ * would let any caller point someone else's notifications at their browser.
566
+ */
567
+ export interface SubscribeNotificationsInput {
568
+ /**
569
+ * The FCM registration token for this browser or app instance, from
570
+ * `getToken()`. 20..4096 characters -- a sanity check rather than a format,
571
+ * since Google publishes no shape for these.
572
+ */
573
+ token: string;
574
+ /**
575
+ * Defaults to `'web'` when the SDK can see a DOM, and is otherwise required:
576
+ * a React Native runtime has a `navigator` but no `document`, and guessing
577
+ * `'web'` there would send the wrong half of every payload.
578
+ */
579
+ platform?: DevicePlatform;
580
+ /**
581
+ * What the user would recognise this device as, for the device list -- up to
582
+ * 200 characters. Left out, the SDK derives something like `Chrome on Windows`
583
+ * from the user agent when it is running in a browser. Cosmetic, never matched
584
+ * on.
585
+ */
586
+ device_label?: string;
587
+ /**
588
+ * The reseller client this frontend belongs to (`tpc_…`), when there is one.
589
+ *
590
+ * Rarely needed: the client id the `CrmClient` already sends is verified and
591
+ * wins. Supplying a *different* one is refused with 403 rather than ignored.
592
+ * Omitted by the white-label frontend, which has no client.
593
+ */
594
+ tpc_id?: string;
595
+ }
596
+ /**
597
+ * `page` and `rows` only.
598
+ *
599
+ * The endpoint's schema accepts `search` because it shares the common list
600
+ * query, but the service does not apply it -- a device list is one person's few
601
+ * rows, so there is nothing to search. It is left out here rather than accepted
602
+ * and silently dropped.
603
+ */
604
+ export type ListDevicesQuery = Pick<ListQuery, 'page' | 'rows'>;