ab-ecosystem-cpass 1.0.7 → 1.0.9
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 +13 -2
- package/dist/client.d.ts +24 -0
- package/dist/client.js +28 -0
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +7 -1
- 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 +25 -22
- package/dist/resources/campaign-messages.js +34 -27
- package/dist/resources/campaign-messages.js.map +1 -1
- package/dist/resources/campaigns.d.ts +33 -3
- package/dist/resources/campaigns.js +36 -2
- package/dist/resources/campaigns.js.map +1 -1
- 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 +39 -7
- 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 +110 -0
- package/docs/campaign/campaign-messages.md +31 -22
- package/docs/campaign/campaigns.md +97 -10
- package/docs/data-store.md +495 -0
- package/package.json +1 -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,113 @@ 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
|
+
A person is matched under **either of their two ids**. A request carries the
|
|
335
|
+
ecosystem's `psuid`, which is what `subscribe()` files a device under; the inbox
|
|
336
|
+
trigger reads its recipient off a chat row the legacy stack wrote, so the id it
|
|
337
|
+
holds is a legacy one. Registration records both, and the lookup matches the one
|
|
338
|
+
id it is given against either field — so it resolves one person under two
|
|
339
|
+
names, and never returns a colleague's devices.
|
|
340
|
+
|
|
341
|
+
The practical consequence: a device registered before this existed is still
|
|
342
|
+
reachable by `psuid` exactly as before, and the next `subscribe()` call — which
|
|
343
|
+
the frontend makes on every app load — fills in the second id.
|
|
344
|
+
|
|
345
|
+
### Rendering it in a browser
|
|
346
|
+
|
|
347
|
+
If the payload carries a `notification` block, FCM displays it and opens
|
|
348
|
+
`webpush.fcmOptions.link` on click with no code from you — which is the
|
|
349
|
+
default worth keeping. A service worker that wants to draw its own instead
|
|
350
|
+
should read the `data` keys above:
|
|
351
|
+
|
|
352
|
+
```js
|
|
353
|
+
messaging.onBackgroundMessage((payload) => {
|
|
354
|
+
self.registration.showNotification(payload.notification.title, {
|
|
355
|
+
body: payload.notification.body,
|
|
356
|
+
icon: payload.data.icon,
|
|
357
|
+
image: payload.data.image,
|
|
358
|
+
data: { url: payload.data.link },
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
self.addEventListener('notificationclick', (event) => {
|
|
363
|
+
event.notification.close();
|
|
364
|
+
event.waitUntil(clients.openWindow(event.notification.data.url));
|
|
365
|
+
});
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
Two things to know if you are porting the existing worker. A notification FCM
|
|
369
|
+
displayed itself nests the payload under `notification.data.FCM_MSG`, so reading
|
|
370
|
+
`event.notification.data.click_action` off it finds nothing — take the link
|
|
371
|
+
from your own `showNotification` call, as above. And `localStorage` does not
|
|
372
|
+
exist in a service worker: a gate written against it throws, and the handler
|
|
373
|
+
around it never runs.
|
|
@@ -1,16 +1,28 @@
|
|
|
1
1
|
# Campaign messages (reporting)
|
|
2
2
|
|
|
3
|
-
`crm.campaignMessages` — `/v1/
|
|
3
|
+
`crm.campaignMessages` — `/v1/campaigns/:id/report`
|
|
4
4
|
|
|
5
5
|
What actually happened to each message a campaign sent. One row per attempted
|
|
6
6
|
send: who it went to, how far it got, what came back, and why it failed.
|
|
7
7
|
|
|
8
8
|
| Action | Route | Scope |
|
|
9
9
|
| --- | --- | --- |
|
|
10
|
-
| [`stats`](#stats) | `GET /v1/
|
|
11
|
-
| [`list`](#list) | `GET /v1/
|
|
10
|
+
| [`stats`](#stats) | `GET /v1/campaigns/:id/report` | `campaigns:read` |
|
|
11
|
+
| [`list`](#list) | `GET /v1/campaigns/:id/report/messages` | `campaigns:read` |
|
|
12
12
|
| [`iterate`](#iterate) | the same, paged for you | `campaigns:read` |
|
|
13
|
-
| [`replies`](#replies) | `GET /v1/
|
|
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
|
+
```
|
|
14
26
|
|
|
15
27
|
## Where these numbers come from
|
|
16
28
|
|
|
@@ -96,23 +108,22 @@ those having gone to one person, and there is one per recipient per message.
|
|
|
96
108
|
## stats
|
|
97
109
|
|
|
98
110
|
```ts
|
|
99
|
-
crm.campaignMessages.stats(
|
|
111
|
+
crm.campaignMessages.stats(campaign: string, query?: CampaignMessageStatsQuery): Promise<CampaignMessageStats>
|
|
100
112
|
```
|
|
101
113
|
|
|
102
|
-
`GET /v1/
|
|
114
|
+
`GET /v1/campaigns/:id/report` · `campaigns:read` · **200**
|
|
103
115
|
|
|
104
116
|
### Query
|
|
105
117
|
|
|
106
118
|
| Field | Type | Required | Notes |
|
|
107
119
|
| --- | --- | --- | --- |
|
|
108
|
-
| `
|
|
109
|
-
| `message` | number | no | One message of a multi-message campaign, zero-based. |
|
|
120
|
+
| `message` | number | no | One message of a multi-message campaign, zero-based. Omit for the whole campaign. |
|
|
110
121
|
|
|
111
122
|
```ts
|
|
112
|
-
const overall = await crm.campaignMessages.stats(
|
|
123
|
+
const overall = await crm.campaignMessages.stats(campaign);
|
|
113
124
|
// { total: 12480, sent: 12410, delivered: 11980, read: 7420, replied: 310, failed: 70 }
|
|
114
125
|
|
|
115
|
-
const second = await crm.campaignMessages.stats(
|
|
126
|
+
const second = await crm.campaignMessages.stats(campaign, { message: 1 });
|
|
116
127
|
```
|
|
117
128
|
|
|
118
129
|
**No date window**, deliberately. A campaign is a single send — its rows are
|
|
@@ -134,16 +145,15 @@ someone else's id is real.
|
|
|
134
145
|
## list
|
|
135
146
|
|
|
136
147
|
```ts
|
|
137
|
-
crm.campaignMessages.list(
|
|
148
|
+
crm.campaignMessages.list(campaign: string, query?: ListCampaignMessagesQuery): Promise<Paginated<CampaignMessageRecord>>
|
|
138
149
|
```
|
|
139
150
|
|
|
140
|
-
`GET /v1/
|
|
151
|
+
`GET /v1/campaigns/:id/report/messages` · `campaigns:read` · **200**
|
|
141
152
|
|
|
142
153
|
### Query
|
|
143
154
|
|
|
144
155
|
| Field | Type | Default | Notes |
|
|
145
156
|
| --- | --- | --- | --- |
|
|
146
|
-
| `campaign` | string | — | Required. |
|
|
147
157
|
| `message` | number | — | Zero-based index of one campaign message. |
|
|
148
158
|
| `status` | `sent` `delivered` `read` `replied` `failed` | — | Cumulative; see below. Omit for every row. |
|
|
149
159
|
| `exact` | boolean | `false` | Only rows sitting at `status` right now. |
|
|
@@ -155,10 +165,10 @@ crm.campaignMessages.list(query: ListCampaignMessagesQuery): Promise<Paginated<C
|
|
|
155
165
|
|
|
156
166
|
```ts
|
|
157
167
|
// the two queries a report screen actually makes
|
|
158
|
-
const failures = await crm.campaignMessages.list(
|
|
168
|
+
const failures = await crm.campaignMessages.list(campaign, { status: 'failed', rows: 100 });
|
|
159
169
|
failures.data[0].error; // why that one did not go
|
|
160
170
|
|
|
161
|
-
const unread = await crm.campaignMessages.list(
|
|
171
|
+
const unread = await crm.campaignMessages.list(campaign, { status: 'delivered', exact: true });
|
|
162
172
|
unread.pagination.total; // equals stats.delivered - stats.read
|
|
163
173
|
```
|
|
164
174
|
|
|
@@ -175,7 +185,7 @@ beyond it.
|
|
|
175
185
|
### Reply filters
|
|
176
186
|
|
|
177
187
|
`reply_type` and `replied_within_hours` are **refused** with
|
|
178
|
-
`
|
|
188
|
+
`422 validation_failed` unless `status` is `replied`. They cannot match anything
|
|
179
189
|
on a non-replied row, and a filter that cannot match is a caller mistake worth
|
|
180
190
|
reporting rather than an empty page.
|
|
181
191
|
|
|
@@ -194,7 +204,7 @@ paging over a list that reorders itself skips and repeats rows.
|
|
|
194
204
|
## iterate
|
|
195
205
|
|
|
196
206
|
```ts
|
|
197
|
-
crm.campaignMessages.iterate(
|
|
207
|
+
crm.campaignMessages.iterate(campaign: string, query?: ListCampaignMessagesQuery): AsyncGenerator<CampaignMessageRecord>
|
|
198
208
|
```
|
|
199
209
|
|
|
200
210
|
Every matching row, paged through for you. **This is what an export is** — there
|
|
@@ -202,7 +212,7 @@ is no export endpoint, nothing to queue, nothing to poll, and no generated file
|
|
|
202
212
|
that expires before someone downloads it.
|
|
203
213
|
|
|
204
214
|
```ts
|
|
205
|
-
for await (const row of crm.campaignMessages.iterate(
|
|
215
|
+
for await (const row of crm.campaignMessages.iterate(campaign)) {
|
|
206
216
|
csv.write([row.identifier, row.name, row.status, row.error ?? '']);
|
|
207
217
|
}
|
|
208
218
|
```
|
|
@@ -221,21 +231,20 @@ repeat or miss a row. For a stable snapshot, export after the campaign completes
|
|
|
221
231
|
## replies
|
|
222
232
|
|
|
223
233
|
```ts
|
|
224
|
-
crm.campaignMessages.replies(
|
|
234
|
+
crm.campaignMessages.replies(campaign: string, query?: CampaignRepliesQuery): Promise<CampaignReplyBreakdown>
|
|
225
235
|
```
|
|
226
236
|
|
|
227
|
-
`GET /v1/
|
|
237
|
+
`GET /v1/campaigns/:id/report/replies` · `campaigns:read` · **200**
|
|
228
238
|
|
|
229
239
|
### Query
|
|
230
240
|
|
|
231
241
|
| Field | Type | Default | Notes |
|
|
232
242
|
| --- | --- | --- | --- |
|
|
233
|
-
| `campaign` | string | — | Required. |
|
|
234
243
|
| `message` | number | — | Zero-based index of one campaign message. |
|
|
235
244
|
| `limit` | number | `50` | Distinct replies to return, 1–200. |
|
|
236
245
|
|
|
237
246
|
```ts
|
|
238
|
-
const { groups, replied, distinct } = await crm.campaignMessages.replies(
|
|
247
|
+
const { groups, replied, distinct } = await crm.campaignMessages.replies(campaign);
|
|
239
248
|
|
|
240
249
|
groups; // [{ reply: 'Yes', replyType: 'button', count: 182 }, { reply: 'No', … }]
|
|
241
250
|
replied; // 310 — every replied row, whether or not its group is listed
|