ab-ecosystem-cpass 1.0.5 → 1.0.6
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 +63 -1
- package/dist/client.d.ts +23 -0
- package/dist/client.js +32 -5
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +10 -0
- package/dist/index.js +22 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/bytes.d.ts +38 -0
- package/dist/internal/bytes.js +84 -0
- package/dist/internal/bytes.js.map +1 -0
- package/dist/internal/device.d.ts +24 -0
- package/dist/internal/device.js +91 -0
- package/dist/internal/device.js.map +1 -0
- package/dist/internal/md5.d.ts +4 -0
- package/dist/internal/md5.js +109 -0
- package/dist/internal/md5.js.map +1 -0
- package/dist/internal/receiver-file.d.ts +106 -0
- package/dist/internal/receiver-file.js +128 -0
- package/dist/internal/receiver-file.js.map +1 -0
- package/dist/resources/audience/notifications.d.ts +171 -0
- package/dist/resources/audience/notifications.js +215 -0
- package/dist/resources/audience/notifications.js.map +1 -0
- package/dist/resources/campaign-audience.d.ts +132 -0
- package/dist/resources/campaign-audience.js +224 -0
- package/dist/resources/campaign-audience.js.map +1 -0
- package/dist/resources/campaign-report-exports.d.ts +14 -0
- package/dist/resources/campaign-report-exports.js +28 -0
- package/dist/resources/campaign-report-exports.js.map +1 -0
- package/dist/resources/campaign-reports.d.ts +15 -0
- package/dist/resources/campaign-reports.js +25 -0
- package/dist/resources/campaign-reports.js.map +1 -0
- package/dist/resources/campaigns.d.ts +239 -0
- package/dist/resources/campaigns.js +300 -0
- package/dist/resources/campaigns.js.map +1 -0
- package/dist/types/audience.d.ts +82 -0
- package/dist/types/campaign.d.ts +229 -0
- package/dist/types/campaign.js +3 -0
- package/dist/types/campaign.js.map +1 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -1
- package/docs/audience/notifications.md +263 -0
- package/docs/campaign/campaigns.md +489 -0
- package/package.json +1 -1
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Campaign shapes. Declared here rather than imported from `@crm/contracts` so
|
|
3
|
+
* the published package has no private dependencies; they are kept in step with
|
|
4
|
+
* the zod schemas in `packages/contracts/src/campaign/`.
|
|
5
|
+
*/
|
|
6
|
+
import type { IsoDate, JsonObject } from './common';
|
|
7
|
+
import type { ContactFilterEntry } from './contact-filter';
|
|
8
|
+
export type CampaignStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled';
|
|
9
|
+
/** What the campaign charges for. `payment` is the collect-a-payment flow. */
|
|
10
|
+
export type CampaignType = 'general' | 'payment';
|
|
11
|
+
export type CampaignTrigger = 'immediate' | 'schedule';
|
|
12
|
+
export type CampaignRepeatUnit = 'minutes' | 'hours' | 'days' | 'weeks' | 'months';
|
|
13
|
+
export interface CampaignRepeat {
|
|
14
|
+
every: number;
|
|
15
|
+
unit: CampaignRepeatUnit;
|
|
16
|
+
/** Inclusive: the last occurrence is the final one at or before this moment. */
|
|
17
|
+
until?: IsoDate;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* A bulk recipient list held in an uploaded file.
|
|
21
|
+
*
|
|
22
|
+
* This is what `campaigns.createBulk()` produces, and it is the only audience
|
|
23
|
+
* mode whose contents are not in the campaign document: the numbers live in an
|
|
24
|
+
* encrypted file and the campaign holds a pointer to it.
|
|
25
|
+
*/
|
|
26
|
+
export interface CampaignNumbersFilter {
|
|
27
|
+
/**
|
|
28
|
+
* HTTPS URL of the encrypted receiver file. Must be a public host -- the
|
|
29
|
+
* service that sends the campaign fetches it.
|
|
30
|
+
*/
|
|
31
|
+
numbers: string;
|
|
32
|
+
/**
|
|
33
|
+
* The secret the file was encrypted under, 32-128 hex characters.
|
|
34
|
+
*
|
|
35
|
+
* Named `hash` for historical reasons; it is a key, not a digest. Whatever
|
|
36
|
+
* reads the file derives the AES key from it. Treat it as a credential: anyone
|
|
37
|
+
* holding this and the URL can read the recipient list.
|
|
38
|
+
*/
|
|
39
|
+
hash: string;
|
|
40
|
+
}
|
|
41
|
+
/** Re-target by what happened to a previous campaign's messages. */
|
|
42
|
+
export interface CampaignRetargetStatusFilter {
|
|
43
|
+
type: 'status';
|
|
44
|
+
/** The campaign to re-target. */
|
|
45
|
+
cid: string;
|
|
46
|
+
/** Its numeric message id. A number, not a string. */
|
|
47
|
+
cmid: number;
|
|
48
|
+
status: 'all' | 'sent' | 'delivered' | 'read' | 'replied' | 'failed';
|
|
49
|
+
delivered?: boolean | null;
|
|
50
|
+
read?: boolean | null;
|
|
51
|
+
replied?: boolean | null;
|
|
52
|
+
/** Hours within which a reply counts. */
|
|
53
|
+
replied_in?: number | null;
|
|
54
|
+
reply_type?: string | null;
|
|
55
|
+
reply?: string[] | null;
|
|
56
|
+
}
|
|
57
|
+
/** Re-target an explicit set of previous recipients. */
|
|
58
|
+
export interface CampaignRetargetSelectionFilter {
|
|
59
|
+
type: 'selection';
|
|
60
|
+
records: string[];
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* One entry in a campaign's `filters`.
|
|
64
|
+
*
|
|
65
|
+
* The audience is one field with four modes, and the mode is decided by the
|
|
66
|
+
* **shape of the array** rather than by a selector:
|
|
67
|
+
*
|
|
68
|
+
* | `filters` | Audience |
|
|
69
|
+
* |---|---|
|
|
70
|
+
* | `[]` | every contact in the account |
|
|
71
|
+
* | `ContactFilterEntry[]` | a segment, built from contact conditions |
|
|
72
|
+
* | `[{numbers, hash}]` | a bulk list from an uploaded file |
|
|
73
|
+
* | `[{type: 'status' \| 'selection', …}]` | re-target of an earlier campaign |
|
|
74
|
+
*
|
|
75
|
+
* The modes cannot be mixed, and the three non-segment modes take exactly one
|
|
76
|
+
* entry -- everything downstream reads `filters[0]` and nothing else. The server
|
|
77
|
+
* rejects a mixture rather than guessing.
|
|
78
|
+
*/
|
|
79
|
+
export type CampaignFilter = CampaignNumbersFilter | CampaignRetargetStatusFilter | CampaignRetargetSelectionFilter | ContactFilterEntry;
|
|
80
|
+
/** Which of the four modes a `filters` array expresses. */
|
|
81
|
+
export type CampaignAudienceKind = 'all' | 'segment' | 'numbers' | 'retarget';
|
|
82
|
+
export interface CampaignMessage {
|
|
83
|
+
id?: string | number;
|
|
84
|
+
channel: {
|
|
85
|
+
id: string;
|
|
86
|
+
version?: string;
|
|
87
|
+
category?: string;
|
|
88
|
+
};
|
|
89
|
+
category?: string;
|
|
90
|
+
/** The provider payload. Shape is per channel type, so it is left open. */
|
|
91
|
+
message: JsonObject;
|
|
92
|
+
/** Save a reply to a contact field. */
|
|
93
|
+
replyStatus?: {
|
|
94
|
+
save: boolean;
|
|
95
|
+
fieldId: string;
|
|
96
|
+
};
|
|
97
|
+
polls?: string[];
|
|
98
|
+
/** Anything else the panel put here is carried through. */
|
|
99
|
+
[key: string]: unknown;
|
|
100
|
+
}
|
|
101
|
+
/** Who launched a campaign and from where. Stamped by the server; never sent. */
|
|
102
|
+
export interface CampaignTriggerInfo {
|
|
103
|
+
userIp?: string;
|
|
104
|
+
userType?: string;
|
|
105
|
+
psuid?: string;
|
|
106
|
+
userRefId?: string;
|
|
107
|
+
[key: string]: unknown;
|
|
108
|
+
}
|
|
109
|
+
export interface Campaign {
|
|
110
|
+
id: string;
|
|
111
|
+
version: string;
|
|
112
|
+
crmVersion: string;
|
|
113
|
+
name: string;
|
|
114
|
+
/**
|
|
115
|
+
* The dispatch route, as the sending service understands it -- `general`,
|
|
116
|
+
* `bulk_campaign`, `retarget`, and others. Distinct from `campaignType`.
|
|
117
|
+
*/
|
|
118
|
+
type: string;
|
|
119
|
+
campaignType: CampaignType;
|
|
120
|
+
trigger: CampaignTrigger;
|
|
121
|
+
triggerInfo: CampaignTriggerInfo;
|
|
122
|
+
channelType: string;
|
|
123
|
+
messagingPattern: string;
|
|
124
|
+
status: CampaignStatus;
|
|
125
|
+
filters: CampaignFilter[];
|
|
126
|
+
messages: CampaignMessage[];
|
|
127
|
+
showMessageInPanel: boolean;
|
|
128
|
+
isRepeating: boolean;
|
|
129
|
+
repeat?: CampaignRepeat;
|
|
130
|
+
parentBroadcastId?: string;
|
|
131
|
+
scheduleId?: string;
|
|
132
|
+
hidden: boolean;
|
|
133
|
+
scheduledAt?: IsoDate;
|
|
134
|
+
processedAt?: IsoDate;
|
|
135
|
+
completedAt?: IsoDate;
|
|
136
|
+
/**
|
|
137
|
+
* Recipients resolved at send time, not at creation.
|
|
138
|
+
*
|
|
139
|
+
* `0` until the campaign is processed. The audience is a stored query -- even a
|
|
140
|
+
* bulk list is a file the sender reads later -- so a scheduled campaign's final
|
|
141
|
+
* count can differ from what was shown when it was built.
|
|
142
|
+
*/
|
|
143
|
+
receivers: number;
|
|
144
|
+
totalMessages: number;
|
|
145
|
+
error?: string;
|
|
146
|
+
metaInfo: JsonObject[];
|
|
147
|
+
createdAt: IsoDate;
|
|
148
|
+
updatedAt: IsoDate;
|
|
149
|
+
}
|
|
150
|
+
export interface CreateCampaignInput {
|
|
151
|
+
name: string;
|
|
152
|
+
/** Defaults to `general`. `createBulk()` sets `bulk_campaign` itself. */
|
|
153
|
+
type?: string;
|
|
154
|
+
campaignType?: CampaignType;
|
|
155
|
+
trigger?: CampaignTrigger;
|
|
156
|
+
channelType?: string;
|
|
157
|
+
messagingPattern?: string;
|
|
158
|
+
/** The audience. `[]` means every contact. See {@link CampaignFilter}. */
|
|
159
|
+
filters?: CampaignFilter[];
|
|
160
|
+
messages: CampaignMessage[];
|
|
161
|
+
showMessageInPanel?: boolean;
|
|
162
|
+
isRepeating?: boolean;
|
|
163
|
+
repeat?: CampaignRepeat;
|
|
164
|
+
parentBroadcastId?: string;
|
|
165
|
+
hidden?: boolean;
|
|
166
|
+
/** Required when `trigger` is `schedule`. */
|
|
167
|
+
scheduledAt?: IsoDate;
|
|
168
|
+
}
|
|
169
|
+
export type UpdateCampaignInput = Partial<CreateCampaignInput>;
|
|
170
|
+
/**
|
|
171
|
+
* Declared as a type alias rather than an interface, and that is load-bearing:
|
|
172
|
+
* only an alias of an object literal gets TypeScript's implicit index signature,
|
|
173
|
+
* which is what lets it be passed as a request's query record.
|
|
174
|
+
*/
|
|
175
|
+
export type ListCampaignsQuery = {
|
|
176
|
+
limit: number;
|
|
177
|
+
cursor?: string;
|
|
178
|
+
status?: CampaignStatus;
|
|
179
|
+
campaignType?: CampaignType;
|
|
180
|
+
type?: string;
|
|
181
|
+
hidden?: boolean;
|
|
182
|
+
};
|
|
183
|
+
/** Delivery counts for one campaign. Every field is a running total. */
|
|
184
|
+
export interface CampaignMetrics {
|
|
185
|
+
queued: number;
|
|
186
|
+
sent: number;
|
|
187
|
+
delivered: number;
|
|
188
|
+
failed: number;
|
|
189
|
+
opened: number;
|
|
190
|
+
clicked: number;
|
|
191
|
+
unsubscribed: number;
|
|
192
|
+
}
|
|
193
|
+
export interface CampaignReport {
|
|
194
|
+
id: string;
|
|
195
|
+
campaignId: string;
|
|
196
|
+
metrics: CampaignMetrics;
|
|
197
|
+
/** When the metrics were last rolled up, which may lag the campaign. */
|
|
198
|
+
generatedAt: IsoDate;
|
|
199
|
+
createdAt: IsoDate;
|
|
200
|
+
updatedAt: IsoDate;
|
|
201
|
+
}
|
|
202
|
+
/** A report export shares the job lifecycle used across the platform. */
|
|
203
|
+
export type CampaignReportExportStatus = 'queued' | 'processing' | 'completed' | 'failed';
|
|
204
|
+
export interface CampaignReportExport {
|
|
205
|
+
id: string;
|
|
206
|
+
campaignId: string;
|
|
207
|
+
format: 'csv' | 'xlsx';
|
|
208
|
+
status: CampaignReportExportStatus;
|
|
209
|
+
rowCount: number;
|
|
210
|
+
/** Present once `status` is `completed`. Expires; see `expiresAt`. */
|
|
211
|
+
fileUrl?: string;
|
|
212
|
+
expiresAt?: IsoDate;
|
|
213
|
+
errorMessage?: string;
|
|
214
|
+
createdAt: IsoDate;
|
|
215
|
+
updatedAt: IsoDate;
|
|
216
|
+
}
|
|
217
|
+
export interface CreateCampaignReportExportInput {
|
|
218
|
+
campaignId: string;
|
|
219
|
+
/** Defaults to `csv`. */
|
|
220
|
+
format?: 'csv' | 'xlsx';
|
|
221
|
+
/** Narrows the rows by the period they fall in. */
|
|
222
|
+
from?: IsoDate;
|
|
223
|
+
to?: IsoDate;
|
|
224
|
+
}
|
|
225
|
+
/** A type alias, for the same reason as {@link ListCampaignsQuery}. */
|
|
226
|
+
export type GetCampaignReportQuery = {
|
|
227
|
+
from?: IsoDate;
|
|
228
|
+
to?: IsoDate;
|
|
229
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"campaign.js","sourceRoot":"","sources":["../../src/types/campaign.ts"],"names":[],"mappings":""}
|
package/dist/types/index.d.ts
CHANGED
package/dist/types/index.js
CHANGED
|
@@ -21,4 +21,6 @@ __exportStar(require("./channels"), exports);
|
|
|
21
21
|
__exportStar(require("./inbox"), exports);
|
|
22
22
|
// After './inbox': a scheduled message's body is a `MessageContent`.
|
|
23
23
|
__exportStar(require("./scheduled-message"), exports);
|
|
24
|
+
// After './contact-filter': a campaign's audience may be a list of its entries.
|
|
25
|
+
__exportStar(require("./campaign"), exports);
|
|
24
26
|
//# sourceMappingURL=index.js.map
|
package/dist/types/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,2CAAyB;AACzB,mDAAiC;AACjC,6CAA2B;AAC3B,6CAA2B;AAC3B,0CAAwB;AACxB,qEAAqE;AACrE,sDAAoC"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,2CAAyB;AACzB,mDAAiC;AACjC,6CAA2B;AAC3B,6CAA2B;AAC3B,0CAAwB;AACxB,qEAAqE;AACrE,sDAAoC;AACpC,gFAAgF;AAChF,6CAA2B"}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# Push notifications
|
|
2
|
+
|
|
3
|
+
`crm.notifications` — `/v1/audience/devices`
|
|
4
|
+
|
|
5
|
+
Which browsers and apps a user wants notifications delivered to. A subscription
|
|
6
|
+
is one device — one browser profile, one app installation — held against the
|
|
7
|
+
acting user, and the only party who can see or revoke it is that user.
|
|
8
|
+
|
|
9
|
+
| Action | Route | Scope |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| [`subscribe`](#subscribe) | `POST /v1/audience/devices` | `devices:write` |
|
|
12
|
+
| [`unsubscribe`](#unsubscribe) | `POST /v1/audience/devices/unregister` | `devices:write` |
|
|
13
|
+
| [`devices`](#devices) | `GET /v1/audience/devices` | `devices:read` |
|
|
14
|
+
|
|
15
|
+
## What the SDK does, and what you do
|
|
16
|
+
|
|
17
|
+
A push subscription has two halves and only one of them is an API call.
|
|
18
|
+
|
|
19
|
+
**In the browser**, which this SDK deliberately does not wrap:
|
|
20
|
+
|
|
21
|
+
```js
|
|
22
|
+
const permission = await Notification.requestPermission(); // 'granted' | 'denied' | 'default'
|
|
23
|
+
const token = await getToken(messaging, { vapidKey }); // firebase/messaging
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Wrapping those two would mean a hard dependency on `firebase/*` — in a package
|
|
27
|
+
that also runs on Node — to pass arguments that come from your own Firebase
|
|
28
|
+
project.
|
|
29
|
+
|
|
30
|
+
**Here**, which is the part that is an API call:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
if (permission === 'granted') {
|
|
34
|
+
await crm.notifications.subscribe({ token });
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Where the subscription lives
|
|
39
|
+
|
|
40
|
+
On the platform, against the acting user, **in this product** — not in the core
|
|
41
|
+
platform. Core owns identity and the agency's Firebase credentials; messages
|
|
42
|
+
arrive here, so the tokens and the sending live here too, and core is never on
|
|
43
|
+
the delivery path.
|
|
44
|
+
|
|
45
|
+
Nothing you send names a Firebase project. The platform resolves it from the
|
|
46
|
+
client the call arrives on and stamps it onto the row, because a registration
|
|
47
|
+
token is only sendable through the project that minted it and nothing in the
|
|
48
|
+
token itself says which. That resolution is a cached read, so it costs nothing in
|
|
49
|
+
steady state — but it is why `subscribe` is the one method here that can answer
|
|
50
|
+
`503`.
|
|
51
|
+
|
|
52
|
+
## The device object
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
interface Device {
|
|
56
|
+
id: string;
|
|
57
|
+
platform: 'web' | 'android' | 'ios';
|
|
58
|
+
label: string; // what you sent as device_label; '' when you sent none
|
|
59
|
+
clientId: string | null; // the reseller frontend it registered from
|
|
60
|
+
lastSeenAt: string; // ISO; last successful send, or last subscribe
|
|
61
|
+
createdAt: string;
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The **registration token is never returned**. It is a send credential: anything
|
|
66
|
+
holding one can push to that browser through the agency's Firebase project. The
|
|
67
|
+
list exists so a user can recognise and revoke their own devices, which needs the
|
|
68
|
+
label and the last-seen time and nothing more.
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## subscribe
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
crm.notifications.subscribe(input: SubscribeNotificationsInput): Promise<Device>
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`POST /v1/audience/devices` · `devices:write` · **201**
|
|
79
|
+
|
|
80
|
+
### Payload
|
|
81
|
+
|
|
82
|
+
| Field | Type | Required | Notes |
|
|
83
|
+
| --- | --- | --- | --- |
|
|
84
|
+
| `token` | string | yes | The FCM registration token for this instance, from `getToken()`. 20–4096 characters — a sanity check, not a format; Google publishes no shape for these. Trimmed. |
|
|
85
|
+
| `platform` | `'web' \| 'android' \| 'ios'` | defaulted | `'web'` when the SDK can see a DOM. Required otherwise — see below. |
|
|
86
|
+
| `device_label` | string | no | Up to 200 characters; longer is trimmed, not refused. Defaults to something like `Chrome on Windows`, derived from the user agent. Cosmetic, never matched on. |
|
|
87
|
+
| `tpc_id` | string | no | The reseller client (`tpc_…`) this frontend belongs to. Rarely needed. |
|
|
88
|
+
|
|
89
|
+
What goes on the wire, with everything defaulted:
|
|
90
|
+
|
|
91
|
+
```http
|
|
92
|
+
POST /v1/audience/devices
|
|
93
|
+
authorization: Bearer <ecosystem token>
|
|
94
|
+
client-id: tpc_8Kd3mXp7qR2wL9zB
|
|
95
|
+
content-type: application/json
|
|
96
|
+
|
|
97
|
+
{
|
|
98
|
+
"token": "fNx8…:APA91bH…",
|
|
99
|
+
"platform": "web",
|
|
100
|
+
"device_label": "Chrome on Windows"
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
const device = await crm.notifications.subscribe({ token });
|
|
106
|
+
// → { id: '…', platform: 'web', label: 'Chrome on Windows', clientId: 'tpc_…', … }
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
There is **no user field**, and that is deliberate: the acting user comes from
|
|
110
|
+
the verified token the client was constructed with. A body that could name its
|
|
111
|
+
own user would let any caller point someone else's notifications at their own
|
|
112
|
+
browser.
|
|
113
|
+
|
|
114
|
+
### Call it on every app load, not once
|
|
115
|
+
|
|
116
|
+
It is an idempotent upsert keyed on the token, so re-sending one the platform
|
|
117
|
+
already holds costs a single indexed update. That is the intended usage, because
|
|
118
|
+
two things change underneath you:
|
|
119
|
+
|
|
120
|
+
- FCM rotates registration tokens on its own schedule, and the old one stops
|
|
121
|
+
being delivered to without anyone being told.
|
|
122
|
+
- A device unseen for **60 days** is pruned, and "seen" means a successful send
|
|
123
|
+
or a call to this method. Someone who receives no notifications for two months
|
|
124
|
+
would otherwise quietly fall off the list.
|
|
125
|
+
|
|
126
|
+
Re-subscribing a token already on file against **another** user moves it rather
|
|
127
|
+
than duplicating it. One browser has one person signed in, and the previous
|
|
128
|
+
owner's notifications have to stop reaching it the moment someone else logs in
|
|
129
|
+
there.
|
|
130
|
+
|
|
131
|
+
### `platform` off-browser
|
|
132
|
+
|
|
133
|
+
The SDK defaults it from `document`, not from `navigator` — which is the
|
|
134
|
+
distinction that matters. React Native defines a `navigator` and no `document`,
|
|
135
|
+
so a navigator-based guess would register every phone as a browser, and the send
|
|
136
|
+
path would fill in the `webpush` block instead of `android`/`apns`: a
|
|
137
|
+
notification FCM accepts and the device never shows. Outside a browser the SDK
|
|
138
|
+
asks instead of guessing:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
await crm.notifications.subscribe({ token, platform: 'android' });
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### `tpc_id`
|
|
145
|
+
|
|
146
|
+
Only ever a confirmation. The `clientId` the `CrmClient` already sends is
|
|
147
|
+
verified by the gateway and wins; this field says which reseller frontend is
|
|
148
|
+
registering, which decides whose Firebase project and which deep-link host apply.
|
|
149
|
+
A value that contradicts the verified client is refused with `403` rather than
|
|
150
|
+
ignored — the two disagreeing means either a frontend bug or an attempt to file a
|
|
151
|
+
device under another tenant, and silently preferring one would hide the first and
|
|
152
|
+
partially succeed at the second. The white-label frontend has no client and sends
|
|
153
|
+
nothing.
|
|
154
|
+
|
|
155
|
+
### Errors
|
|
156
|
+
|
|
157
|
+
| Status | Code | Meaning |
|
|
158
|
+
| --- | --- | --- |
|
|
159
|
+
| 400 | `validation_failed` | Token missing or implausible, or `platform` could not be defaulted. Thrown by the SDK before a request is made. |
|
|
160
|
+
| 403 | `forbidden` | `tpc_id` names a different client than the token was issued for. |
|
|
161
|
+
| 503 | `push_not_configured` | The agency has no Firebase notification resource. Nothing the caller can fix; push is simply off for this account. |
|
|
162
|
+
| 503 | `push_unavailable` | The platform could not read the configuration from core. Transient. |
|
|
163
|
+
|
|
164
|
+
Both `503`s want the same handling as success: try again on the next app load,
|
|
165
|
+
which the advice above means you are doing anyway.
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## unsubscribe
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
crm.notifications.unsubscribe(token: string): Promise<void>
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
`POST /v1/audience/devices/unregister` · `devices:write` · **204**
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
await crm.notifications.unsubscribe(token);
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Payload
|
|
182
|
+
|
|
183
|
+
| Field | Type | Required | Notes |
|
|
184
|
+
| --- | --- | --- | --- |
|
|
185
|
+
| `token` | string | yes | The same registration token. Trimmed. |
|
|
186
|
+
|
|
187
|
+
`204` with no body **whether or not a row went** — you are asking for a
|
|
188
|
+
guarantee about the future, and a token that is already gone provides it. So this
|
|
189
|
+
is safe to call unconditionally on logout without first checking whether the
|
|
190
|
+
device was ever subscribed.
|
|
191
|
+
|
|
192
|
+
Scoped to the caller's own devices. A token identifies a browser rather than a
|
|
193
|
+
person, so by the time one user logs out the row may already belong to whoever
|
|
194
|
+
logged in after them; an unscoped delete would cut off the person now holding it.
|
|
195
|
+
|
|
196
|
+
A `POST` with the token in the body rather than `DELETE /devices/:token`: a
|
|
197
|
+
registration token is hundreds of characters and may contain `:` and `/`, and
|
|
198
|
+
putting one in a path invites proxy length limits and writes a live send
|
|
199
|
+
credential into every access log along the way.
|
|
200
|
+
|
|
201
|
+
### Also delete it on the device
|
|
202
|
+
|
|
203
|
+
This withdraws the platform's permission to send to that token. If the user is
|
|
204
|
+
turning notifications **off** — rather than logging out — also drop the token in
|
|
205
|
+
the browser, or the next `getToken()` hands back the same one and the
|
|
206
|
+
subscription can come back by accident:
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
await crm.notifications.unsubscribe(token);
|
|
210
|
+
await deleteToken(messaging); // firebase/messaging
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## devices
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
crm.notifications.devices(query?: Partial<ListDevicesQuery>): Promise<Paginated<Device>>
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
`GET /v1/audience/devices` · `devices:read` · **200**
|
|
222
|
+
|
|
223
|
+
### Query
|
|
224
|
+
|
|
225
|
+
| Field | Type | Default | Notes |
|
|
226
|
+
| --- | --- | --- | --- |
|
|
227
|
+
| `page` | number | `1` | 1-based. |
|
|
228
|
+
| `rows` | number | `25` | 1–100. |
|
|
229
|
+
|
|
230
|
+
Sorted by `lastSeenAt` descending. This is what a "notification settings" screen
|
|
231
|
+
lists.
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
const { data } = await crm.notifications.devices();
|
|
235
|
+
data.map((d) => `${d.label || 'This device'} · last seen ${d.lastSeenAt}`);
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
No `search`: the endpoint shares the common list query so the server would accept
|
|
239
|
+
one, but the service does not apply it — a device list is one person's handful of
|
|
240
|
+
rows. It is left out of the SDK type rather than accepted and silently dropped.
|
|
241
|
+
|
|
242
|
+
Only ever the acting user's own devices. There is no account-wide view and no
|
|
243
|
+
device id in the path: a device is one person's browser.
|
|
244
|
+
|
|
245
|
+
Worth knowing: a notification is sent to the **20 most recently seen** devices
|
|
246
|
+
of a user. A longer list is itself the answer to "why did my old laptop stop
|
|
247
|
+
buzzing".
|
|
248
|
+
|
|
249
|
+
### Telling whether *this* browser is subscribed
|
|
250
|
+
|
|
251
|
+
You can't, from here — tokens are not returned, by design. Decide it locally
|
|
252
|
+
instead. The browser knows its own `Notification.permission` and its own token,
|
|
253
|
+
and because `subscribe` is an idempotent upsert, the cheap and correct answer to
|
|
254
|
+
"am I still subscribed?" is to subscribe again:
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
if (Notification.permission === 'granted') {
|
|
258
|
+
await crm.notifications.subscribe({ token: await getToken(messaging, { vapidKey }) });
|
|
259
|
+
}
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
That also repairs the two cases a stored flag gets wrong: a rotated token, and a
|
|
263
|
+
row pruned for staleness.
|