ab-ecosystem-cpass 1.0.6 → 1.0.8
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 +5 -2
- package/dist/client.d.ts +31 -6
- package/dist/client.js +36 -8
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +9 -5
- package/dist/index.js.map +1 -1
- package/dist/resources/audience/canned-replies.d.ts +95 -0
- package/dist/resources/audience/canned-replies.js +112 -0
- package/dist/resources/audience/canned-replies.js.map +1 -0
- package/dist/resources/audience/notifications.d.ts +14 -0
- package/dist/resources/audience/notifications.js +14 -0
- package/dist/resources/audience/notifications.js.map +1 -1
- package/dist/resources/campaign-messages.d.ts +153 -0
- package/dist/resources/campaign-messages.js +179 -0
- package/dist/resources/campaign-messages.js.map +1 -0
- package/dist/resources/data-stores.d.ts +469 -0
- package/dist/resources/data-stores.js +530 -0
- package/dist/resources/data-stores.js.map +1 -0
- package/dist/resources/paths.d.ts +6 -0
- package/dist/resources/paths.js +7 -1
- package/dist/resources/paths.js.map +1 -1
- package/dist/types/campaign.d.ts +137 -41
- package/dist/types/canned-reply.d.ts +124 -0
- package/dist/types/canned-reply.js +6 -0
- package/dist/types/canned-reply.js.map +1 -0
- package/dist/types/datastore.d.ts +260 -0
- package/dist/types/datastore.js +21 -0
- package/dist/types/datastore.js.map +1 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.js +3 -0
- package/dist/types/index.js.map +1 -1
- package/docs/audience/canned-replies.md +302 -0
- package/docs/audience/notifications.md +99 -0
- package/docs/campaign/campaign-messages.md +282 -0
- package/docs/campaign/campaigns.md +8 -48
- package/docs/data-store.md +495 -0
- package/package.json +1 -1
- package/dist/resources/campaign-report-exports.d.ts +0 -14
- package/dist/resources/campaign-report-exports.js +0 -28
- package/dist/resources/campaign-report-exports.js.map +0 -1
- package/dist/resources/campaign-reports.d.ts +0 -15
- package/dist/resources/campaign-reports.js +0 -25
- package/dist/resources/campaign-reports.js.map +0 -1
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
# Canned replies
|
|
2
|
+
|
|
3
|
+
`crm.cannedReplies` — `/v1/audience/canned-replies`
|
|
4
|
+
|
|
5
|
+
Message payloads an agent sends into a chat without retyping them. The panel
|
|
6
|
+
offers them when an agent types `/` followed by the reply's name.
|
|
7
|
+
|
|
8
|
+
Two kinds, and the difference is who gets the last word:
|
|
9
|
+
|
|
10
|
+
- **`CANNED`** — every message the reply holds is sent exactly as stored, in
|
|
11
|
+
order, the moment it is picked. A three-message reply puts three messages on
|
|
12
|
+
the contact's phone from one keystroke.
|
|
13
|
+
- **`QUICK`** — the reply's text lands in the agent's composer to edit before
|
|
14
|
+
sending. Exactly one message, of type `text`: there is one composer, and
|
|
15
|
+
nowhere in it to put a second message or an image.
|
|
16
|
+
|
|
17
|
+
| Action | Route | Scope |
|
|
18
|
+
| --- | --- | --- |
|
|
19
|
+
| [`list`](#list) | `GET /v1/audience/canned-replies` | `canned_replies:read` |
|
|
20
|
+
| [`get`](#get) | `GET /v1/audience/canned-replies/:reply` | `canned_replies:read` |
|
|
21
|
+
| [`create`](#create) | `POST /v1/audience/canned-replies` | `canned_replies:write` |
|
|
22
|
+
| [`update`](#update) | `PATCH /v1/audience/canned-replies/:reply` | `canned_replies:write` |
|
|
23
|
+
| [`delete`](#delete) | `DELETE /v1/audience/canned-replies/:reply` | `canned_replies:write` |
|
|
24
|
+
|
|
25
|
+
## Addressing a reply: `:reply` is an id **or** a name
|
|
26
|
+
|
|
27
|
+
A 24-hex-character path segment is read as an **id**; anything else as a
|
|
28
|
+
**name**. Names are unique per account, which is what makes addressing by the
|
|
29
|
+
name an agent typed safe — and it is the only identifier the panel has at the
|
|
30
|
+
moment of picking one.
|
|
31
|
+
|
|
32
|
+
There is no fallback between the two. A 24-hex id that no longer exists is a
|
|
33
|
+
`404`, never a name lookup, so a request for a deleted reply cannot quietly match
|
|
34
|
+
a different one that happened to be named after its id. The one real ambiguity is
|
|
35
|
+
a reply *named* 24 hex characters: it would be read as an id and 404. Rename it.
|
|
36
|
+
|
|
37
|
+
## The reply object
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
interface CannedReply {
|
|
41
|
+
id: string;
|
|
42
|
+
name: string; // unique per account; what the agent types after `/`
|
|
43
|
+
type: 'CANNED' | 'QUICK';
|
|
44
|
+
channel: 'whatsapp' | 'sms' | 'email' | 'rcs' | 'webchat';
|
|
45
|
+
messages: CannedReplyMessage[];
|
|
46
|
+
createdAt: string; // ISO
|
|
47
|
+
updatedAt: string; // ISO
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`channel` is a channel **type**, never a channel id, so one reply works across
|
|
52
|
+
every number on the account. `whatsapp` is the only value the panel offers today,
|
|
53
|
+
and the reason is `whatsapp_api` below: a stored message is a literal WhatsApp
|
|
54
|
+
Cloud API message object, so a reply is only sendable on a channel that speaks
|
|
55
|
+
that shape.
|
|
56
|
+
|
|
57
|
+
`messages` comes back in full from **every** endpoint, `list` included. It is the
|
|
58
|
+
entire point of the record, and a picker that had to fetch each reply before it
|
|
59
|
+
could show what the reply says would make one request per keystroke.
|
|
60
|
+
|
|
61
|
+
## The message object
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
interface CannedReplyMessageV2 {
|
|
65
|
+
version: '2';
|
|
66
|
+
whatsapp_api: MessageContent; // the payload as the provider takes it
|
|
67
|
+
internal?: Record<string, unknown>; // the editor's working copy; never read by the server
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`whatsapp_api` is the same object `crm.inbox.messages.send()` accepts as
|
|
72
|
+
`content`. That is the point: picking a reply *is* sending its payloads, with no
|
|
73
|
+
translation step in between.
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{ "version": "2",
|
|
77
|
+
"whatsapp_api": { "type": "text", "text": { "body": "Hi!", "preview_url": false } } }
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
```json
|
|
81
|
+
{ "version": "2",
|
|
82
|
+
"whatsapp_api": { "type": "image", "image": { "link": "https://...", "caption": "Our menu" } } }
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{ "version": "2",
|
|
87
|
+
"whatsapp_api": { "type": "interactive", "interactive": {
|
|
88
|
+
"type": "button",
|
|
89
|
+
"body": { "text": "Pick one" },
|
|
90
|
+
"action": { "buttons": [ { "type": "reply", "reply": { "id": "btn_0", "title": "Yes" } } ] } } } }
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`internal` is the form state a composer needs to **reopen** the message for
|
|
94
|
+
editing — which media type was chosen, whether the upload was still local, the
|
|
95
|
+
button rows before they were flattened into an action. Stored and returned
|
|
96
|
+
verbatim, never read by the server. It is optional: a reply created through the
|
|
97
|
+
API without one is valid, and an editor just opens it in whatever state it can
|
|
98
|
+
infer from `whatsapp_api`.
|
|
99
|
+
|
|
100
|
+
### Variables are not substituted here
|
|
101
|
+
|
|
102
|
+
`{{first_name}}` and friends are stored **literally**. Substitution happens at
|
|
103
|
+
the moment of sending, against the contact in front of the agent — there is no
|
|
104
|
+
contact when a reply is written, so there is nothing here to substitute against.
|
|
105
|
+
|
|
106
|
+
### Reading older rows
|
|
107
|
+
|
|
108
|
+
Rows written by the previous editor are still in the collection and carry a
|
|
109
|
+
different shape entirely: a flat `type` plus a `components` array in template
|
|
110
|
+
form. They come back exactly as stored, because synthesising a `whatsapp_api` for
|
|
111
|
+
them would mean inventing a payload nobody approved.
|
|
112
|
+
|
|
113
|
+
Narrow before you read:
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
for (const message of reply.messages) {
|
|
117
|
+
if (message.version === '2') {
|
|
118
|
+
send(message.whatsapp_api);
|
|
119
|
+
} else {
|
|
120
|
+
// A v1 row. Render it read-only, or ask the user to re-save the reply.
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
New writes are v2 only — there is no way to create a v1 row through this API.
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## list
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
crm.cannedReplies.list(query?: Partial<ListCannedRepliesQuery>): Promise<Paginated<CannedReply>>
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`GET /v1/audience/canned-replies` · `canned_replies:read` · **200**
|
|
136
|
+
|
|
137
|
+
Offset paginated, newest first.
|
|
138
|
+
|
|
139
|
+
### Query
|
|
140
|
+
|
|
141
|
+
| Field | Type | Default | Notes |
|
|
142
|
+
| --- | --- | --- | --- |
|
|
143
|
+
| `page` | number | `1` | 1-based. |
|
|
144
|
+
| `rows` | number | `25` | 1–100. |
|
|
145
|
+
| `search` | string | — | 1–120 chars. Matches the **name**, case-insensitively, anywhere in it. The term is escaped, so `(` and `.` are searched for rather than compiled. |
|
|
146
|
+
| `type` | `'CANNED' \| 'QUICK'` | — | — |
|
|
147
|
+
| `channel` | channel **type** | — | e.g. `whatsapp`. Not a channel id. |
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
// What the chat panel asks for: the replies usable on the open chat's channel.
|
|
151
|
+
const { data } = await crm.cannedReplies.list({ channel: 'whatsapp', rows: 100 });
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
To count without fetching, ask for one row and read the total:
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
const { pagination } = await crm.cannedReplies.list({ rows: 1 });
|
|
158
|
+
pagination.total; // how many replies the account has
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
> There is no `isListing` flag. Legacy had one, and all it dropped from the
|
|
162
|
+
> response was `createdAt` and `updatedAt` — it kept `messages`, the only field
|
|
163
|
+
> in the record with any weight.
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## get
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
crm.cannedReplies.get(ref: string): Promise<CannedReply>
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
`GET /v1/audience/canned-replies/:reply` · `canned_replies:read` · **200**
|
|
174
|
+
|
|
175
|
+
`ref` is an id or a name — see [addressing](#addressing-a-reply-reply-is-an-id-or-a-name).
|
|
176
|
+
|
|
177
|
+
`404 not_found` if the reply is not the caller's.
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
const reply = await crm.cannedReplies.get('price list');
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## create
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
crm.cannedReplies.create(input: CreateCannedReplyInput): Promise<CannedReply>
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`POST /v1/audience/canned-replies` · `canned_replies:write` · **201**
|
|
192
|
+
|
|
193
|
+
### Body
|
|
194
|
+
|
|
195
|
+
| Field | Type | Required | Default | Notes |
|
|
196
|
+
| --- | --- | --- | --- | --- |
|
|
197
|
+
| `name` | string | yes | — | Trimmed, 1–120 chars. Unique per account. |
|
|
198
|
+
| `type` | `'CANNED' \| 'QUICK'` | yes | — | — |
|
|
199
|
+
| `channel` | channel type | no | `'whatsapp'` | — |
|
|
200
|
+
| `messages` | `CannedReplyMessageInput[]` | yes | — | 1–10 entries. See [the message object](#the-message-object). |
|
|
201
|
+
|
|
202
|
+
`messages[].version` defaults to `'2'` and is the only accepted value;
|
|
203
|
+
`messages[].whatsapp_api` is required; `messages[].internal` is optional.
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
const reply = await crm.cannedReplies.create({
|
|
207
|
+
name: 'price list',
|
|
208
|
+
type: 'CANNED',
|
|
209
|
+
channel: 'whatsapp',
|
|
210
|
+
messages: [
|
|
211
|
+
{ version: '2', whatsapp_api: { type: 'text', text: { body: 'Here you go' } } },
|
|
212
|
+
{ version: '2', whatsapp_api: {
|
|
213
|
+
type: 'document',
|
|
214
|
+
document: { link: 'https://.../prices.pdf', filename: 'prices.pdf' } } },
|
|
215
|
+
],
|
|
216
|
+
});
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### Errors
|
|
220
|
+
|
|
221
|
+
| Status | Code | When |
|
|
222
|
+
| --- | --- | --- |
|
|
223
|
+
| 409 | `conflict` | The name is already used on this account. |
|
|
224
|
+
| 422 | `invalid_canned_reply` | A `QUICK` reply holds more than one message, or one that is not `text`. |
|
|
225
|
+
| 422 | `validation_failed` | `messages` is empty or longer than 10; a message has no `whatsapp_api.type`; `name` is blank. |
|
|
226
|
+
|
|
227
|
+
The `QUICK` rule is enforced here because legacy did not: it stored a `QUICK`
|
|
228
|
+
reply holding an image happily, and the panel then refused to use it forever —
|
|
229
|
+
the only explanation being a toast at the moment an agent reached for it.
|
|
230
|
+
|
|
231
|
+
---
|
|
232
|
+
|
|
233
|
+
## update
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
crm.cannedReplies.update(ref: string, input: UpdateCannedReplyInput): Promise<CannedReply>
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
`PATCH /v1/audience/canned-replies/:reply` · `canned_replies:write` · **200**
|
|
240
|
+
|
|
241
|
+
`ref` is an id or a name. The body is any subset of `create`'s.
|
|
242
|
+
|
|
243
|
+
`messages` is replaced **whole**, never merged: a reply is an ordered sequence,
|
|
244
|
+
and there is no sensible way to patch element two of a list whose length is also
|
|
245
|
+
changing. Send the array you want stored.
|
|
246
|
+
|
|
247
|
+
Renaming works. It silently did not in legacy, whose update built its `$set` from
|
|
248
|
+
`messages` alone and discarded every other field — while still *requiring* `type`
|
|
249
|
+
and `channel` in the request it then ignored.
|
|
250
|
+
|
|
251
|
+
An empty body is a no-op and returns the reply as it stands.
|
|
252
|
+
|
|
253
|
+
### Errors
|
|
254
|
+
|
|
255
|
+
| Status | Code | When |
|
|
256
|
+
| --- | --- | --- |
|
|
257
|
+
| 404 | `not_found` | No such reply on this account. |
|
|
258
|
+
| 409 | `conflict` | A rename into a name that is already used. |
|
|
259
|
+
| 422 | `invalid_canned_reply` | The **merged** result breaks the `QUICK` rule. |
|
|
260
|
+
|
|
261
|
+
That last one is checked against the merge, so flipping a three-message reply to
|
|
262
|
+
`QUICK` is refused — while renaming one that *already* breaks the rule is not.
|
|
263
|
+
The check runs only when the patch touches `type` or `messages`: a rename alone
|
|
264
|
+
leaves a reply exactly as valid as it already was, and running the rule
|
|
265
|
+
regardless would make a v1 row unrenameable, punishing existing data for a rule
|
|
266
|
+
introduced after it was written.
|
|
267
|
+
|
|
268
|
+
```ts
|
|
269
|
+
await crm.cannedReplies.update('price list', {
|
|
270
|
+
messages: [{ version: '2', whatsapp_api: { type: 'text', text: { body: 'Updated prices attached' } } }],
|
|
271
|
+
});
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
---
|
|
275
|
+
|
|
276
|
+
## delete
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
crm.cannedReplies.delete(ref: string): Promise<void>
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
`DELETE /v1/audience/canned-replies/:reply` · `canned_replies:write` · **204**, no body.
|
|
283
|
+
|
|
284
|
+
`ref` is an id or a name. `404 not_found` if the reply is not the caller's.
|
|
285
|
+
|
|
286
|
+
Nothing else holds a reference to a canned reply — a sent message is a message
|
|
287
|
+
row, with its own copy of the content — so a delete leaves nothing dangling.
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
## Events
|
|
292
|
+
|
|
293
|
+
Each write publishes one, for panels that cache the list:
|
|
294
|
+
|
|
295
|
+
| Event | Payload |
|
|
296
|
+
| --- | --- |
|
|
297
|
+
| `canned_reply.created` | the full `CannedReply` |
|
|
298
|
+
| `canned_reply.updated` | the full `CannedReply` |
|
|
299
|
+
| `canned_reply.deleted` | `{ id, name }` |
|
|
300
|
+
|
|
301
|
+
The delete carries the **name** as well as the id, because a panel caching
|
|
302
|
+
replies by name — which is how an agent picks them — has no id to evict by.
|
|
@@ -261,3 +261,102 @@ if (Notification.permission === 'granted') {
|
|
|
261
261
|
|
|
262
262
|
That also repairs the two cases a stored flag gets wrong: a rotated token, and a
|
|
263
263
|
row pruned for staleness.
|
|
264
|
+
|
|
265
|
+
---
|
|
266
|
+
|
|
267
|
+
## What a device actually receives
|
|
268
|
+
|
|
269
|
+
Subscribing is only half the story. This is what arrives, so a frontend can
|
|
270
|
+
render it and an app can deep-link from it.
|
|
271
|
+
|
|
272
|
+
Today one thing notifies: **a message on a chat the device's owner is
|
|
273
|
+
responsible for.** The payload is FCM's, with the application's own keys under
|
|
274
|
+
`data`:
|
|
275
|
+
|
|
276
|
+
```json
|
|
277
|
+
{
|
|
278
|
+
"notification": { "title": "Priya", "body": "Is this still available?" },
|
|
279
|
+
"data": {
|
|
280
|
+
"chat": "68f1c2a39b4e5d6f70812345",
|
|
281
|
+
"action_title": "View In Chat Panel",
|
|
282
|
+
"link": "https://app.acme.com/products/crmv2/chat/68f1c2a39b4e5d6f70812345",
|
|
283
|
+
"click_action": "https://app.acme.com/products/crmv2/chat/68f1c2a39b4e5d6f70812345",
|
|
284
|
+
"icon": "https://cdn.acme.com/push-192.png",
|
|
285
|
+
"badge": "https://cdn.acme.com/push-192.png"
|
|
286
|
+
},
|
|
287
|
+
"webpush": {
|
|
288
|
+
"notification": { "icon": "https://cdn.acme.com/push-192.png" },
|
|
289
|
+
"fcmOptions": { "link": "https://app.acme.com/products/crmv2/chat/68f1c2a39b4e5d6f70812345" }
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
| Key | What it is |
|
|
295
|
+
| --- | --- |
|
|
296
|
+
| `notification.title` | Who wrote — the contact's first name, or `New User`. Not the agency's sender name, which is what every other notification would use. |
|
|
297
|
+
| `notification.body` | The message text, cut to 180 characters. A non-text message is named by its type instead: `Image Message`, `Location Message`. |
|
|
298
|
+
| `data.chat` | The chat id. The same id the inbox endpoints take. |
|
|
299
|
+
| `data.click_action` | **Web:** the absolute panel URL. **Android/iOS:** the bare chat id, because an app resolves a record rather than opening a URL. |
|
|
300
|
+
| `data.link` | Always the absolute URL, on every platform, so a native client can open the panel if it would rather. |
|
|
301
|
+
| `data.image` | An image message's media URL, for a preview. Absent otherwise. |
|
|
302
|
+
| `data.icon`, `data.badge` | The agency's notification icon, for a client drawing the notification itself. |
|
|
303
|
+
| `webpush.fcmOptions.link` | What a **browser** opens on click. This is the mechanism, not `click_action`. |
|
|
304
|
+
|
|
305
|
+
The host in every URL is the one configured for that device's surface, so the
|
|
306
|
+
same notification deep-links to `app.acme.com` for one reseller's user and
|
|
307
|
+
`portal.other.com` for another's.
|
|
308
|
+
|
|
309
|
+
### Which messages notify
|
|
310
|
+
|
|
311
|
+
| The message | Notifies |
|
|
312
|
+
| --- | --- |
|
|
313
|
+
| A contact wrote in | yes |
|
|
314
|
+
| Somebody replied from the WhatsApp Business app | yes |
|
|
315
|
+
| An agent replied from the panel | no |
|
|
316
|
+
| A bot replied | no |
|
|
317
|
+
| A campaign sent | no |
|
|
318
|
+
| An API send | no |
|
|
319
|
+
| A delivery receipt moved to delivered or read | no |
|
|
320
|
+
|
|
321
|
+
A tick changing on a screen is not worth interrupting anyone for, and nobody
|
|
322
|
+
needs telling about a message they just sent themselves.
|
|
323
|
+
|
|
324
|
+
### Who it reaches
|
|
325
|
+
|
|
326
|
+
The chat's **assigned agent**, or the **account owner** while it is unassigned
|
|
327
|
+
— one person, not the team. An unassigned chat belongs to whoever is minding
|
|
328
|
+
the queue.
|
|
329
|
+
|
|
330
|
+
There is no presence check: an agent with the conversation open on screen still
|
|
331
|
+
gets the push on their phone. That is the behaviour the panel has always had, and
|
|
332
|
+
changing it is a product decision rather than a detail of delivery.
|
|
333
|
+
|
|
334
|
+
### Rendering it in a browser
|
|
335
|
+
|
|
336
|
+
If the payload carries a `notification` block, FCM displays it and opens
|
|
337
|
+
`webpush.fcmOptions.link` on click with no code from you — which is the
|
|
338
|
+
default worth keeping. A service worker that wants to draw its own instead
|
|
339
|
+
should read the `data` keys above:
|
|
340
|
+
|
|
341
|
+
```js
|
|
342
|
+
messaging.onBackgroundMessage((payload) => {
|
|
343
|
+
self.registration.showNotification(payload.notification.title, {
|
|
344
|
+
body: payload.notification.body,
|
|
345
|
+
icon: payload.data.icon,
|
|
346
|
+
image: payload.data.image,
|
|
347
|
+
data: { url: payload.data.link },
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
self.addEventListener('notificationclick', (event) => {
|
|
352
|
+
event.notification.close();
|
|
353
|
+
event.waitUntil(clients.openWindow(event.notification.data.url));
|
|
354
|
+
});
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
Two things to know if you are porting the existing worker. A notification FCM
|
|
358
|
+
displayed itself nests the payload under `notification.data.FCM_MSG`, so reading
|
|
359
|
+
`event.notification.data.click_action` off it finds nothing — take the link
|
|
360
|
+
from your own `showNotification` call, as above. And `localStorage` does not
|
|
361
|
+
exist in a service worker: a gate written against it throws, and the handler
|
|
362
|
+
around it never runs.
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# Campaign messages (reporting)
|
|
2
|
+
|
|
3
|
+
`crm.campaignMessages` — `/v1/campaigns/:id/report`
|
|
4
|
+
|
|
5
|
+
What actually happened to each message a campaign sent. One row per attempted
|
|
6
|
+
send: who it went to, how far it got, what came back, and why it failed.
|
|
7
|
+
|
|
8
|
+
| Action | Route | Scope |
|
|
9
|
+
| --- | --- | --- |
|
|
10
|
+
| [`stats`](#stats) | `GET /v1/campaigns/:id/report` | `campaigns:read` |
|
|
11
|
+
| [`list`](#list) | `GET /v1/campaigns/:id/report/messages` | `campaigns:read` |
|
|
12
|
+
| [`iterate`](#iterate) | the same, paged for you | `campaigns:read` |
|
|
13
|
+
| [`replies`](#replies) | `GET /v1/campaigns/:id/report/replies` | `campaigns:read` |
|
|
14
|
+
|
|
15
|
+
## The campaign is the path
|
|
16
|
+
|
|
17
|
+
A report has no meaning without a campaign, so it is addressed as the campaign's
|
|
18
|
+
own sub-resource rather than as a collection of rows that a campaign filters.
|
|
19
|
+
Every method takes the id first, and the options object holds only what
|
|
20
|
+
*narrows* a report that already exists — a message index, a status, a page.
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
await crm.campaignMessages.stats(campaignId);
|
|
24
|
+
await crm.campaignMessages.list(campaignId, { status: 'failed' });
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Where these numbers come from
|
|
28
|
+
|
|
29
|
+
The campaign manager writes one row as it hands each message to a channel. The
|
|
30
|
+
channel's delivery webhooks — processed **outside the CRM** — then move that
|
|
31
|
+
row's `status` forward as the message is delivered, read and replied to.
|
|
32
|
+
|
|
33
|
+
So reporting is a **read of those rows, counted when you ask**. There is no
|
|
34
|
+
report collection, no roll-up job, and nothing to regenerate: no `generatedAt` to
|
|
35
|
+
reason about, and no window in which a stored number is quietly wrong because a
|
|
36
|
+
webhook has arrived but the roll-up has not run.
|
|
37
|
+
|
|
38
|
+
The flip side is that a campaign still going out has moving numbers. Reading
|
|
39
|
+
`stats` seconds after launch under-counts everything, and that is the truth
|
|
40
|
+
rather than a lag.
|
|
41
|
+
|
|
42
|
+
Nothing in this module writes. There is no endpoint to correct a status: the
|
|
43
|
+
service that owns the lifecycle is the one receiving the webhooks.
|
|
44
|
+
|
|
45
|
+
## The funnel
|
|
46
|
+
|
|
47
|
+
`status` is a **single field holding the furthest stage reached** — not a set of
|
|
48
|
+
flags. A row at `read` is also delivered and sent; a row at `replied` is all
|
|
49
|
+
three. So the counts nest:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
replied ⊆ read ⊆ delivered ⊆ sent failed ∥ (outside the funnel)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Which means:
|
|
56
|
+
|
|
57
|
+
- **Cumulative by default.** `delivered` is everything that got at least as far
|
|
58
|
+
as delivered — the number a "delivered" tile shows.
|
|
59
|
+
- **Subtract for a stage's own count.** `delivered - read` is "delivered but
|
|
60
|
+
unread"; `read - replied` is "read but silent".
|
|
61
|
+
- **`failed` is never inside `sent`.** A failed message was never sent anywhere;
|
|
62
|
+
it is counted on its own.
|
|
63
|
+
- **There is no `queued`.** A row exists only once a send has been attempted, so
|
|
64
|
+
"waiting to go out" is not a state this collection can have. The campaign's own
|
|
65
|
+
`status` is where that lives.
|
|
66
|
+
|
|
67
|
+
`total` counts every row, failures included, and is the number of attempted
|
|
68
|
+
sends.
|
|
69
|
+
|
|
70
|
+
## The row
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
interface CampaignMessageRecord {
|
|
74
|
+
id: string; // the row's own id — what a `selection` retarget takes
|
|
75
|
+
campaignId: string;
|
|
76
|
+
messageIndex: number; // which message of the campaign, zero-based
|
|
77
|
+
providerMessageId: string; // '' when the send failed
|
|
78
|
+
channelId: string;
|
|
79
|
+
contactId: string; // the recipient's contact id
|
|
80
|
+
identifier: string; // the destination: a WhatsApp number, a Telegram id…
|
|
81
|
+
name: string; // '' when the contact had no name
|
|
82
|
+
status: 'sent' | 'delivered' | 'read' | 'replied' | 'failed';
|
|
83
|
+
sentAt?: string; // set per stage reached, absent for stages not reached
|
|
84
|
+
deliveredAt?: string;
|
|
85
|
+
readAt?: string;
|
|
86
|
+
repliedAt?: string;
|
|
87
|
+
failedAt?: string;
|
|
88
|
+
reply: string; // '' unless replied
|
|
89
|
+
replyType: string; // 'text', 'button', … '' unless replied
|
|
90
|
+
repliedIn: number; // seconds between the send and the reply; 0 unless replied
|
|
91
|
+
error?: string; // failed rows only
|
|
92
|
+
createdAt: string; // when the send was attempted
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`id` is worth singling out: it is the handle a **`selection` retarget** campaign
|
|
97
|
+
takes. Collect the ids of the rows you want and the campaign manager resolves
|
|
98
|
+
them back to recipients, which is how "re-send to everyone who did not read it"
|
|
99
|
+
is built.
|
|
100
|
+
|
|
101
|
+
`CampaignMessageRecord` is not `CampaignMessage`, despite the names. A
|
|
102
|
+
`CampaignMessage` is one of the messages a campaign is *defined* to send — a
|
|
103
|
+
channel, a payload, its polls. A `CampaignMessageRecord` is a record of one of
|
|
104
|
+
those having gone to one person, and there is one per recipient per message.
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## stats
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
crm.campaignMessages.stats(campaign: string, query?: CampaignMessageStatsQuery): Promise<CampaignMessageStats>
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`GET /v1/campaigns/:id/report` · `campaigns:read` · **200**
|
|
115
|
+
|
|
116
|
+
### Query
|
|
117
|
+
|
|
118
|
+
| Field | Type | Required | Notes |
|
|
119
|
+
| --- | --- | --- | --- |
|
|
120
|
+
| `message` | number | no | One message of a multi-message campaign, zero-based. Omit for the whole campaign. |
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
const overall = await crm.campaignMessages.stats(campaign);
|
|
124
|
+
// { total: 12480, sent: 12410, delivered: 11980, read: 7420, replied: 310, failed: 70 }
|
|
125
|
+
|
|
126
|
+
const second = await crm.campaignMessages.stats(campaign, { message: 1 });
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
**No date window**, deliberately. A campaign is a single send — its rows are
|
|
130
|
+
written over the minutes it takes to go out and then only change state — so "the
|
|
131
|
+
campaign's whole life" is the only window that means anything. A repeating
|
|
132
|
+
campaign's runs are separate campaigns, each with its own id.
|
|
133
|
+
|
|
134
|
+
One aggregation, not five counts. Legacy issued a `countDocuments` per stage,
|
|
135
|
+
which is five passes for numbers that all come from one grouping, and could
|
|
136
|
+
return a `read` higher than its own `delivered` when a webhook landed between
|
|
137
|
+
two of the passes.
|
|
138
|
+
|
|
139
|
+
`404 not_found` when the campaign is not the caller's — the same answer as a
|
|
140
|
+
campaign that does not exist, so the error cannot be used to probe whether
|
|
141
|
+
someone else's id is real.
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## list
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
crm.campaignMessages.list(campaign: string, query?: ListCampaignMessagesQuery): Promise<Paginated<CampaignMessageRecord>>
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
`GET /v1/campaigns/:id/report/messages` · `campaigns:read` · **200**
|
|
152
|
+
|
|
153
|
+
### Query
|
|
154
|
+
|
|
155
|
+
| Field | Type | Default | Notes |
|
|
156
|
+
| --- | --- | --- | --- |
|
|
157
|
+
| `message` | number | — | Zero-based index of one campaign message. |
|
|
158
|
+
| `status` | `sent` `delivered` `read` `replied` `failed` | — | Cumulative; see below. Omit for every row. |
|
|
159
|
+
| `exact` | boolean | `false` | Only rows sitting at `status` right now. |
|
|
160
|
+
| `identifier` | string | — | Exact match on one recipient. Not a search. |
|
|
161
|
+
| `reply_type` | string | — | Replied rows only; refused otherwise. |
|
|
162
|
+
| `replied_within_hours` | number | — | Replied rows only; refused otherwise. |
|
|
163
|
+
| `page` | number | `1` | 1-based. |
|
|
164
|
+
| `rows` | number | `25` | 1–100. |
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
// the two queries a report screen actually makes
|
|
168
|
+
const failures = await crm.campaignMessages.list(campaign, { status: 'failed', rows: 100 });
|
|
169
|
+
failures.data[0].error; // why that one did not go
|
|
170
|
+
|
|
171
|
+
const unread = await crm.campaignMessages.list(campaign, { status: 'delivered', exact: true });
|
|
172
|
+
unread.pagination.total; // equals stats.delivered - stats.read
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### `status` and `exact`
|
|
176
|
+
|
|
177
|
+
`status` filters **cumulatively**, so a drill-down returns exactly the rows the
|
|
178
|
+
matching stat counted: `status: 'delivered'` gives you the `delivered` number's
|
|
179
|
+
rows. `exact: true` switches to the single stage — "delivered but never read" —
|
|
180
|
+
which is the drill-down for `delivered - read`.
|
|
181
|
+
|
|
182
|
+
`exact` has no effect without `status`, and none on `failed`, which has nothing
|
|
183
|
+
beyond it.
|
|
184
|
+
|
|
185
|
+
### Reply filters
|
|
186
|
+
|
|
187
|
+
`reply_type` and `replied_within_hours` are **refused** with
|
|
188
|
+
`422 validation_failed` unless `status` is `replied`. They cannot match anything
|
|
189
|
+
on a non-replied row, and a filter that cannot match is a caller mistake worth
|
|
190
|
+
reporting rather than an empty page.
|
|
191
|
+
|
|
192
|
+
`replied_within_hours: 2` means the reply arrived within two hours of the send —
|
|
193
|
+
useful for separating people who answered immediately from people who came back
|
|
194
|
+
the next day.
|
|
195
|
+
|
|
196
|
+
### Ordering
|
|
197
|
+
|
|
198
|
+
Newest attempt first, by when the row was **written**, not by a stage timestamp.
|
|
199
|
+
Sorting on `deliveredAt` would reorder the list as webhooks arrive, and offset
|
|
200
|
+
paging over a list that reorders itself skips and repeats rows.
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## iterate
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
crm.campaignMessages.iterate(campaign: string, query?: ListCampaignMessagesQuery): AsyncGenerator<CampaignMessageRecord>
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Every matching row, paged through for you. **This is what an export is** — there
|
|
211
|
+
is no export endpoint, nothing to queue, nothing to poll, and no generated file
|
|
212
|
+
that expires before someone downloads it.
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
for await (const row of crm.campaignMessages.iterate(campaign)) {
|
|
216
|
+
csv.write([row.identifier, row.name, row.status, row.error ?? '']);
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Pages at 100 rows unless you pass `rows` — the server's maximum, and so the
|
|
221
|
+
fewest round trips. A million recipients is ten thousand requests, so run an
|
|
222
|
+
export that size somewhere with low latency to the gateway rather than from a
|
|
223
|
+
browser tab.
|
|
224
|
+
|
|
225
|
+
The collection is live. Rows are written while a campaign goes out and statuses
|
|
226
|
+
change under an export that takes minutes, and offset paging over moving data can
|
|
227
|
+
repeat or miss a row. For a stable snapshot, export after the campaign completes.
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## replies
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
crm.campaignMessages.replies(campaign: string, query?: CampaignRepliesQuery): Promise<CampaignReplyBreakdown>
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
`GET /v1/campaigns/:id/report/replies` · `campaigns:read` · **200**
|
|
238
|
+
|
|
239
|
+
### Query
|
|
240
|
+
|
|
241
|
+
| Field | Type | Default | Notes |
|
|
242
|
+
| --- | --- | --- | --- |
|
|
243
|
+
| `message` | number | — | Zero-based index of one campaign message. |
|
|
244
|
+
| `limit` | number | `50` | Distinct replies to return, 1–200. |
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
const { groups, replied, distinct } = await crm.campaignMessages.replies(campaign);
|
|
248
|
+
|
|
249
|
+
groups; // [{ reply: 'Yes', replyType: 'button', count: 182 }, { reply: 'No', … }]
|
|
250
|
+
replied; // 310 — every replied row, whether or not its group is listed
|
|
251
|
+
distinct; // 4 — distinct replies in total
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
What a poll or a button message is sent to find out. Grouped on the reply **and**
|
|
255
|
+
its type, so a tapped `Yes` and a typed `Yes` stay apart — they say different
|
|
256
|
+
things about how engaged the recipient was. Most common first, ties broken by the
|
|
257
|
+
reply text so the order is stable between reads.
|
|
258
|
+
|
|
259
|
+
`distinct` against `groups.length` tells you whether the breakdown is complete.
|
|
260
|
+
A campaign that asked an open question has as many groups as it has repliers, so
|
|
261
|
+
a truncated answer there is normal rather than a problem — raise `limit`, or
|
|
262
|
+
treat the tail as "other".
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
## Operational note: the index this needs
|
|
267
|
+
|
|
268
|
+
Every read here matches on the campaign first, and the collection — which belongs
|
|
269
|
+
to the campaign manager — declares no index on it. Without one, each report is a
|
|
270
|
+
scan of every campaign message ever sent:
|
|
271
|
+
|
|
272
|
+
```js
|
|
273
|
+
db.newcampaignmessages.createIndex(
|
|
274
|
+
{ cid: 1, cmid: 1, status: 1 },
|
|
275
|
+
{ name: 'Campaign Report', background: true },
|
|
276
|
+
);
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
One compound index serves all three reads. This service deliberately does **not**
|
|
280
|
+
create it: building an index on a multi-million row collection owned by another
|
|
281
|
+
service, as a side effect of a process starting, is not something that should
|
|
282
|
+
happen by accident.
|