@proveanything/smartlinks 1.6.1 → 1.6.3
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/dist/api/comms.d.ts +27 -1
- package/dist/api/comms.js +30 -0
- package/dist/docs/API_SUMMARY.md +378 -2
- package/dist/docs/comms.md +581 -0
- package/dist/types/comms.d.ts +64 -0
- package/docs/API_SUMMARY.md +378 -2
- package/docs/comms.md +581 -0
- package/package.json +1 -1
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
# Communications (Comms & Broadcasts)
|
|
2
|
+
|
|
3
|
+
This guide covers the full communications surface of the SDK: transactional sends, multi-channel broadcasts, consent management, push registration, and analytics.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## What You Can Do
|
|
8
|
+
|
|
9
|
+
- **Transactional Send** — Fire a single targeted message to one contact without creating a broadcast record.
|
|
10
|
+
- **Broadcasts** — Define and enqueue multi-channel campaigns (email, push, SMS, wallet); preview and test before sending.
|
|
11
|
+
- **Comms Settings** — Configure topics and unsubscribe behaviour per collection.
|
|
12
|
+
- **Consent & Preferences** — Record opt-ins/outs and per-subject preferences.
|
|
13
|
+
- **Method Registration** — Register email, SMS, and Web Push contact methods.
|
|
14
|
+
- **Analytics** — Query delivery events, recipient lists, and log custom events.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Transactional Send
|
|
19
|
+
|
|
20
|
+
Sends a single message to one contact using a template. No broadcast record is created. The send is logged to the contact's communication history with `sourceType: 'transactional'`.
|
|
21
|
+
|
|
22
|
+
**Endpoint:** `POST /admin/collection/:collectionId/comm/send`
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
import { comms } from '@proveanything/smartlinks'
|
|
26
|
+
import type { TransactionalSendRequest } from '@proveanything/smartlinks'
|
|
27
|
+
|
|
28
|
+
const result = await comms.sendTransactional('collectionId', {
|
|
29
|
+
contactId: 'e4f2a1b0-...',
|
|
30
|
+
templateId: 'warranty-update',
|
|
31
|
+
channel: 'preferred', // 'email' | 'sms' | 'push' | 'wallet' | 'preferred' (default)
|
|
32
|
+
props: { claimRef: 'CLM-0042', decision: 'approved' },
|
|
33
|
+
include: {
|
|
34
|
+
productId: 'prod-abc123', // hydrate product context into template
|
|
35
|
+
appCase: 'c9d1e2f3-...', // hydrate an app case record
|
|
36
|
+
},
|
|
37
|
+
ref: 'warranty-decision-notification', // arbitrary string for your own tracking
|
|
38
|
+
appId: 'warrantyApp',
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
if (result.ok) {
|
|
42
|
+
console.log(`Sent via ${result.channel}`, result.messageId)
|
|
43
|
+
} else {
|
|
44
|
+
console.error('Send failed:', result.error)
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Request Body
|
|
49
|
+
|
|
50
|
+
| Field | Type | Description |
|
|
51
|
+
|-------|------|-------------|
|
|
52
|
+
| `contactId` | `string` | **Required.** Contact to send to. |
|
|
53
|
+
| `templateId` | `string` | **Required.** Template to render. |
|
|
54
|
+
| `channel` | `'email' \| 'sms' \| 'push' \| 'wallet' \| 'preferred'` | Channel to send on. `'preferred'` (default) picks the contact's best available channel. |
|
|
55
|
+
| `props` | `Record<string, unknown>` | Extra variables merged into template rendering. |
|
|
56
|
+
| `include` | object | Hydration flags — see table below. |
|
|
57
|
+
| `ref` | `string` | Arbitrary reference string stored with the event. |
|
|
58
|
+
| `appId` | `string` | App identifier, used for context/logging. |
|
|
59
|
+
|
|
60
|
+
**`include` fields:**
|
|
61
|
+
|
|
62
|
+
| Field | Type | Description |
|
|
63
|
+
|-------|------|-------------|
|
|
64
|
+
| `collection` | `boolean` | Include collection data in template context. |
|
|
65
|
+
| `productId` | `string` | Hydrate a specific product. |
|
|
66
|
+
| `proofId` | `string` | Hydrate a specific proof. |
|
|
67
|
+
| `user` | `boolean` | Include the requesting user's data. |
|
|
68
|
+
| `appCase` | `string` | Hydrate an app case record by ID. |
|
|
69
|
+
| `appThread` | `string` | Hydrate an app thread record by ID. |
|
|
70
|
+
| `appRecord` | `string` | Hydrate an app record by ID. |
|
|
71
|
+
|
|
72
|
+
### Response
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
// Success
|
|
76
|
+
{ ok: true; channel: 'email' | 'sms' | 'push' | 'wallet'; messageId?: string }
|
|
77
|
+
|
|
78
|
+
// Failure
|
|
79
|
+
{ ok: false; error: string }
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Broadcasts
|
|
85
|
+
|
|
86
|
+
Broadcasts are multi-channel campaigns defined and managed under a collection. Create a broadcast record in the platform first, then use the SDK to preview, test, and enqueue.
|
|
87
|
+
|
|
88
|
+
**Import:**
|
|
89
|
+
```typescript
|
|
90
|
+
import { broadcasts } from '@proveanything/smartlinks'
|
|
91
|
+
import type { BroadcastSendRequest } from '@proveanything/smartlinks'
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Preview
|
|
95
|
+
|
|
96
|
+
Render a broadcast template for a given channel without sending.
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
const preview = await broadcasts.preview('collectionId', 'broadcastId', {
|
|
100
|
+
email: 'user@example.com', // optional: override recipient email for preview
|
|
101
|
+
channelOverride: 'push', // force a specific channel
|
|
102
|
+
props: { productId: 'prod_123' },
|
|
103
|
+
hydrate: true,
|
|
104
|
+
include: { product: true, proof: true }
|
|
105
|
+
})
|
|
106
|
+
// preview.channel — which channel was rendered
|
|
107
|
+
// preview.payload / preview.subject / preview.body — channel-specific render output
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Test Send
|
|
111
|
+
|
|
112
|
+
Send a single test message to a contact.
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
await broadcasts.sendTest('collectionId', 'broadcastId', {
|
|
116
|
+
contactId: 'contact_123',
|
|
117
|
+
props: { promo: 'JAN' }
|
|
118
|
+
})
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Enqueue Production Send
|
|
122
|
+
|
|
123
|
+
Enqueue a background send to all recipients. When `channel` is omitted the broadcast's own mode setting drives channel selection:
|
|
124
|
+
- `preferred` — picks the best single channel per recipient.
|
|
125
|
+
- `channels` / `all` — sends on every enabled channel.
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
const body: BroadcastSendRequest = {
|
|
129
|
+
pageSize: 200,
|
|
130
|
+
hydrate: true,
|
|
131
|
+
include: { product: true, proof: true, user: true }
|
|
132
|
+
}
|
|
133
|
+
await broadcasts.send('collectionId', 'broadcastId', body)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Manual Send
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
await broadcasts.sendManual('collectionId', 'broadcastId', { /* overrides */ })
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Topic Targeting and Consent
|
|
143
|
+
|
|
144
|
+
A broadcast must declare a `topic` under `broadcast.data.topic` (e.g. `newsletter`, `critical`). The topic key must match one defined in your collection's comms settings, and consent enforcement uses it at send time.
|
|
145
|
+
|
|
146
|
+
Consent resolution order (per recipient, per channel):
|
|
147
|
+
1. `preferences._default.topicsByChannel[channel][topic]`
|
|
148
|
+
2. `preferences._default.channels[channel]`
|
|
149
|
+
3. `preferences._default.topics[topic]`
|
|
150
|
+
4. Subject-specific preferences (when hydrating a subject context)
|
|
151
|
+
|
|
152
|
+
Minimal broadcast data shape:
|
|
153
|
+
```json
|
|
154
|
+
{
|
|
155
|
+
"data": {
|
|
156
|
+
"topic": "newsletter",
|
|
157
|
+
"channelSettings": {
|
|
158
|
+
"mode": "preferred",
|
|
159
|
+
"channels": [
|
|
160
|
+
{ "channel": "email", "enabled": true, "priority": 1, "templateId": "tmpl_newsletter_email" },
|
|
161
|
+
{ "channel": "push", "enabled": true, "priority": 2 }
|
|
162
|
+
]
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Append Recipients and Events
|
|
169
|
+
|
|
170
|
+
```typescript
|
|
171
|
+
// Append a single delivery event
|
|
172
|
+
await broadcasts.append('collectionId', {
|
|
173
|
+
broadcastId: 'broadcastId',
|
|
174
|
+
contactId: 'contact_123',
|
|
175
|
+
channel: 'email',
|
|
176
|
+
eventType: 'delivered',
|
|
177
|
+
outcome: 'success'
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
// Append bulk events
|
|
181
|
+
await broadcasts.appendBulk('collectionId', {
|
|
182
|
+
params: { broadcastId: 'broadcastId', channel: 'push' },
|
|
183
|
+
ids: ['contact_1', 'contact_2'],
|
|
184
|
+
idField: 'contactId'
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
// List recipients
|
|
188
|
+
const recips = await broadcasts.recipients('collectionId', 'broadcastId', { limit: 50 })
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## Admin Comms Settings
|
|
194
|
+
|
|
195
|
+
Configure unsubscribe behaviour and topics per collection.
|
|
196
|
+
|
|
197
|
+
**Base path:** `admin/collection/:collectionId/comm.settings`
|
|
198
|
+
|
|
199
|
+
### Get Settings
|
|
200
|
+
|
|
201
|
+
```typescript
|
|
202
|
+
import { comms } from '@proveanything/smartlinks'
|
|
203
|
+
const { settings } = await comms.getSettings('collectionId', { includeSecret: true })
|
|
204
|
+
// settings.unsub — { requireToken, hasSecret }
|
|
205
|
+
// settings.topics — map of topic key → config
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### Patch Settings
|
|
209
|
+
|
|
210
|
+
```typescript
|
|
211
|
+
await comms.patchSettings('collectionId', {
|
|
212
|
+
unsub: { requireToken: true, secret: '<your-secret>' },
|
|
213
|
+
topics: { /* ... */ }
|
|
214
|
+
})
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
To clear the unsubscribe secret, send `secret: ''`.
|
|
218
|
+
|
|
219
|
+
### Topics Config
|
|
220
|
+
|
|
221
|
+
Each topic key maps to a config object:
|
|
222
|
+
|
|
223
|
+
```json
|
|
224
|
+
{
|
|
225
|
+
"newsletter": {
|
|
226
|
+
"label": "Newsletter",
|
|
227
|
+
"description": "Occasional updates and stories",
|
|
228
|
+
"classification": "marketing",
|
|
229
|
+
"defaults": {
|
|
230
|
+
"policy": "opt-in",
|
|
231
|
+
"byChannel": { "email": "opt-in", "push": "opt-in" },
|
|
232
|
+
"channels": { "email": true, "push": true, "sms": false }
|
|
233
|
+
},
|
|
234
|
+
"rules": {
|
|
235
|
+
"allowChannels": ["email", "sms", "push"],
|
|
236
|
+
"allowUnsubscribe": true
|
|
237
|
+
},
|
|
238
|
+
"required": false
|
|
239
|
+
},
|
|
240
|
+
"critical": {
|
|
241
|
+
"label": "Critical Notices",
|
|
242
|
+
"classification": "transactional",
|
|
243
|
+
"defaults": {
|
|
244
|
+
"policy": "opt-out",
|
|
245
|
+
"channels": { "email": true, "push": true }
|
|
246
|
+
},
|
|
247
|
+
"rules": { "allowChannels": ["email", "push"], "allowUnsubscribe": false },
|
|
248
|
+
"required": true
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
- `classification`: `'transactional'` or `'marketing'` — used by the UI when no explicit policy is set.
|
|
254
|
+
- `defaults.policy`: `'opt-in'` or `'opt-out'` — fallback policy for channels not overridden by `byChannel`.
|
|
255
|
+
- `required: true` and `rules.allowUnsubscribe: false` — use for critical/mandatory messages.
|
|
256
|
+
|
|
257
|
+
### Unsubscribe Tokens
|
|
258
|
+
|
|
259
|
+
If `unsub.requireToken` is true, public unsubscribe calls must include a `token` query param.
|
|
260
|
+
|
|
261
|
+
Token generation (topic opt-out):
|
|
262
|
+
```
|
|
263
|
+
basis = "${contactId}:${topic}"
|
|
264
|
+
token = sha256(basis + ":" + unsub.secret) // hex
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Token generation (channel opt-out):
|
|
268
|
+
```
|
|
269
|
+
basis = "${contactId}:channel:${channel}"
|
|
270
|
+
token = sha256(basis + ":" + unsub.secret)
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Browser helper:
|
|
274
|
+
```typescript
|
|
275
|
+
async function sha256Hex(input: string): Promise<string> {
|
|
276
|
+
const data = new TextEncoder().encode(input)
|
|
277
|
+
const hash = await crypto.subtle.digest('SHA-256', data)
|
|
278
|
+
return Array.from(new Uint8Array(hash))
|
|
279
|
+
.map(b => b.toString(16).padStart(2, '0'))
|
|
280
|
+
.join('')
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const token = await sha256Hex(`${contactId}:${topic}:${secret}`)
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
---
|
|
287
|
+
|
|
288
|
+
## Public Comms API
|
|
289
|
+
|
|
290
|
+
All public endpoints live under `public/collection/:collectionId/comm/*` and do not require an API key.
|
|
291
|
+
|
|
292
|
+
### Get Topics
|
|
293
|
+
|
|
294
|
+
```typescript
|
|
295
|
+
const { topics } = await comms.getPublicTopics('collectionId')
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Each topic carries UI metadata:
|
|
299
|
+
- `classification`: `'transactional'` | `'marketing'`
|
|
300
|
+
- `defaults.policy`: `'opt-in'` | `'opt-out'`
|
|
301
|
+
- `defaults.byChannel[channel]`: per-channel override
|
|
302
|
+
|
|
303
|
+
Helper to resolve effective policy for a toggle UI:
|
|
304
|
+
```typescript
|
|
305
|
+
type Policy = 'opt-in' | 'opt-out'
|
|
306
|
+
|
|
307
|
+
function effectivePolicy(topic: any, channel: 'email' | 'sms' | 'push' | 'wallet'): Policy {
|
|
308
|
+
return (
|
|
309
|
+
topic?.defaults?.byChannel?.[channel] ??
|
|
310
|
+
topic?.defaults?.policy ??
|
|
311
|
+
(topic?.classification === 'marketing' ? 'opt-in' : 'opt-out')
|
|
312
|
+
) as Policy
|
|
313
|
+
}
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
### Consent (Default)
|
|
317
|
+
|
|
318
|
+
Record a contact's default channel and topic opt-ins.
|
|
319
|
+
|
|
320
|
+
```typescript
|
|
321
|
+
await comms.upsertConsent('collectionId', {
|
|
322
|
+
contactId: 'contact_123',
|
|
323
|
+
channels: { email: true, push: true },
|
|
324
|
+
topics: { newsletter: true, critical: true },
|
|
325
|
+
topicsByChannel: { email: { newsletter: true }, push: { critical: true } }
|
|
326
|
+
})
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
### Preferences (Subject-Specific)
|
|
330
|
+
|
|
331
|
+
Record consent specific to a product, proof, or other subject. Omit `subject` to update defaults.
|
|
332
|
+
|
|
333
|
+
```typescript
|
|
334
|
+
await comms.upsertPreferences('collectionId', {
|
|
335
|
+
contactId: 'contact_123',
|
|
336
|
+
subject: { type: 'product', id: 'prod_1' },
|
|
337
|
+
channels: { email: true },
|
|
338
|
+
topics: { updates: true }
|
|
339
|
+
})
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
### Subscribe / Unsubscribe (Subject)
|
|
343
|
+
|
|
344
|
+
Manage subscriptions to a specific subject (e.g. product updates, proof alerts).
|
|
345
|
+
|
|
346
|
+
```typescript
|
|
347
|
+
// Subscribe
|
|
348
|
+
const { subscriptionId } = await comms.subscribe('collectionId', {
|
|
349
|
+
contactId: 'contact_123',
|
|
350
|
+
subject: { type: 'proof', id: 'prf_1', productId: 'prod_1' },
|
|
351
|
+
subscribe: true,
|
|
352
|
+
source: 'api'
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
// Unsubscribe (same call, subscribe: false)
|
|
356
|
+
await comms.subscribe('collectionId', {
|
|
357
|
+
contactId: 'contact_123',
|
|
358
|
+
subject: { type: 'proof', id: 'prf_1', productId: 'prod_1' },
|
|
359
|
+
subscribe: false,
|
|
360
|
+
source: 'api'
|
|
361
|
+
})
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
### Check Subscription
|
|
365
|
+
|
|
366
|
+
```typescript
|
|
367
|
+
const r = await comms.checkSubscription('collectionId', {
|
|
368
|
+
contactId: 'contact_123',
|
|
369
|
+
subjectType: 'proof',
|
|
370
|
+
subjectId: 'prf_1',
|
|
371
|
+
productId: 'prod_1'
|
|
372
|
+
})
|
|
373
|
+
// r.subscribed === true | false
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
### Resolve Subscriptions
|
|
377
|
+
|
|
378
|
+
Find contacts that are subscribed to a subject using identity hints.
|
|
379
|
+
|
|
380
|
+
```typescript
|
|
381
|
+
const res = await comms.resolveSubscriptions('collectionId', {
|
|
382
|
+
subject: { type: 'product', id: 'prod_1' },
|
|
383
|
+
hints: { userId: 'user_1', email: 'user@example.com' }
|
|
384
|
+
})
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
### List and Register Methods
|
|
388
|
+
|
|
389
|
+
```typescript
|
|
390
|
+
// List registered methods for a contact
|
|
391
|
+
const { methods } = await comms.listMethods('collectionId', {
|
|
392
|
+
contactId: 'contact_123',
|
|
393
|
+
type: 'email' // optional filter: 'email' | 'sms' | 'push'
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
// Register email
|
|
397
|
+
await comms.registerEmail('collectionId', {
|
|
398
|
+
contactId: 'contact_123',
|
|
399
|
+
email: 'user@example.com'
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
// Register SMS
|
|
403
|
+
await comms.registerSms('collectionId', {
|
|
404
|
+
contactId: 'contact_123',
|
|
405
|
+
phone: '+12065550100'
|
|
406
|
+
})
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
### Unsubscribe (Public Link / One-Click)
|
|
410
|
+
|
|
411
|
+
```typescript
|
|
412
|
+
await comms.unsubscribe('collectionId', {
|
|
413
|
+
contactId: 'contact_123',
|
|
414
|
+
topic: 'newsletter',
|
|
415
|
+
token: '<sha256-hex-when-requireToken-is-true>'
|
|
416
|
+
})
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
Omit `topic` to unsubscribe from all. Omit `channel` to apply across all channels.
|
|
420
|
+
|
|
421
|
+
---
|
|
422
|
+
|
|
423
|
+
## Push Registration
|
|
424
|
+
|
|
425
|
+
Web Push requires a service worker and the browser's Push API.
|
|
426
|
+
|
|
427
|
+
### 1. Fetch the VAPID Public Key
|
|
428
|
+
|
|
429
|
+
```typescript
|
|
430
|
+
import { comms } from '@proveanything/smartlinks'
|
|
431
|
+
const { publicKey } = await comms.getPushVapidPublicKey('collectionId')
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
### 2. Subscribe in the Service Worker
|
|
435
|
+
|
|
436
|
+
```typescript
|
|
437
|
+
const registration = await navigator.serviceWorker.ready
|
|
438
|
+
const subscription = await registration.pushManager.subscribe({
|
|
439
|
+
userVisibleOnly: true,
|
|
440
|
+
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
|
441
|
+
})
|
|
442
|
+
const subJson = subscription.toJSON() as any
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
### 3. Register the Method
|
|
446
|
+
|
|
447
|
+
```typescript
|
|
448
|
+
await comms.registerPush('collectionId', {
|
|
449
|
+
contactId: 'contact_123',
|
|
450
|
+
endpoint: subscription.endpoint,
|
|
451
|
+
keys: subJson?.keys,
|
|
452
|
+
meta: { userId: 'user_1' }
|
|
453
|
+
})
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
### VAPID Key Utility
|
|
457
|
+
|
|
458
|
+
```typescript
|
|
459
|
+
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
|
460
|
+
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
|
|
461
|
+
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
|
|
462
|
+
const rawData = atob(base64)
|
|
463
|
+
const output = new Uint8Array(rawData.length)
|
|
464
|
+
for (let i = 0; i < rawData.length; i++) output[i] = rawData.charCodeAt(i)
|
|
465
|
+
return output
|
|
466
|
+
}
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
---
|
|
470
|
+
|
|
471
|
+
## Analytics
|
|
472
|
+
|
|
473
|
+
### Query Events by User / Contact
|
|
474
|
+
|
|
475
|
+
```typescript
|
|
476
|
+
const events = await comms.queryByUser('collectionId', {
|
|
477
|
+
userId: 'user_123',
|
|
478
|
+
limit: 100
|
|
479
|
+
})
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
### Recipient IDs for a Source
|
|
483
|
+
|
|
484
|
+
```typescript
|
|
485
|
+
const ids = await comms.queryRecipientIds('collectionId', {
|
|
486
|
+
broadcastId: 'br_456'
|
|
487
|
+
})
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
### Recipients Without an Action
|
|
491
|
+
|
|
492
|
+
```typescript
|
|
493
|
+
const withoutOpen = await comms.queryRecipientsWithoutAction('collectionId', {
|
|
494
|
+
broadcastId: 'br_456',
|
|
495
|
+
actionId: 'open'
|
|
496
|
+
})
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
### Recipients With an Action
|
|
500
|
+
|
|
501
|
+
```typescript
|
|
502
|
+
const withClick = await comms.queryRecipientsWithAction('collectionId', {
|
|
503
|
+
broadcastId: 'br_456',
|
|
504
|
+
actionId: 'click',
|
|
505
|
+
includeOutcome: true // returns RecipientWithOutcome[] instead of RecipientId[]
|
|
506
|
+
})
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
### Log Events
|
|
510
|
+
|
|
511
|
+
```typescript
|
|
512
|
+
// Single event
|
|
513
|
+
await comms.logCommunicationEvent('collectionId', {
|
|
514
|
+
eventType: 'opened',
|
|
515
|
+
channel: 'email',
|
|
516
|
+
contactId: 'contact_123'
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
// Bulk events (same params applied to all IDs)
|
|
520
|
+
await comms.logBulkCommunicationEvents('collectionId', {
|
|
521
|
+
params: { broadcastId: 'br_456', channel: 'email', eventType: 'delivered' },
|
|
522
|
+
ids: ['contact_1', 'contact_2', 'contact_3'],
|
|
523
|
+
idField: 'contactId'
|
|
524
|
+
})
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
---
|
|
528
|
+
|
|
529
|
+
## End-to-End Admin Workflow
|
|
530
|
+
|
|
531
|
+
A typical sequence from setup to delivery:
|
|
532
|
+
|
|
533
|
+
```typescript
|
|
534
|
+
import { comms, broadcasts } from '@proveanything/smartlinks'
|
|
535
|
+
|
|
536
|
+
// 1. Configure topics and unsubscribe rules
|
|
537
|
+
await comms.patchSettings('collectionId', {
|
|
538
|
+
unsub: { requireToken: true, secret: process.env.UNSUB_SECRET },
|
|
539
|
+
topics: {
|
|
540
|
+
newsletter: {
|
|
541
|
+
label: 'Newsletter', classification: 'marketing',
|
|
542
|
+
defaults: { policy: 'opt-in', channels: { email: true, push: true } },
|
|
543
|
+
rules: { allowChannels: ['email', 'push', 'sms'], allowUnsubscribe: true }
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
// 2. Register contact methods (done at contact creation / profile update)
|
|
549
|
+
await comms.registerEmail('collectionId', { contactId: 'c_123', email: 'user@example.com' })
|
|
550
|
+
|
|
551
|
+
// 3. Record consent (done when user confirms opt-in)
|
|
552
|
+
await comms.upsertConsent('collectionId', {
|
|
553
|
+
contactId: 'c_123',
|
|
554
|
+
channels: { email: true }, topics: { newsletter: true }
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
// 4a. Transactional: send a one-off triggered message
|
|
558
|
+
const result = await comms.sendTransactional('collectionId', {
|
|
559
|
+
contactId: 'c_123', templateId: 'order-confirmed',
|
|
560
|
+
channel: 'email', props: { orderId: 'ORD-001' }
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
// 4b. Broadcast: preview → test → enqueue
|
|
564
|
+
const preview = await broadcasts.preview('collectionId', 'br_spring', {
|
|
565
|
+
channelOverride: 'email', hydrate: true, include: { product: true }
|
|
566
|
+
})
|
|
567
|
+
await broadcasts.sendTest('collectionId', 'br_spring', { contactId: 'c_123' })
|
|
568
|
+
await broadcasts.send('collectionId', 'br_spring', { pageSize: 200, hydrate: true })
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
---
|
|
572
|
+
|
|
573
|
+
## Prerequisites
|
|
574
|
+
|
|
575
|
+
| Feature | Requirement |
|
|
576
|
+
|---------|-------------|
|
|
577
|
+
| Email | SendGrid credentials (env or collection comms settings) |
|
|
578
|
+
| Push | `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` env vars |
|
|
579
|
+
| SMS | Twilio credentials (env or collection comms settings) |
|
|
580
|
+
| Wallet | Google Wallet issuer + service account |
|
|
581
|
+
| Hydration | Set `hydrate: true` and populate `include` on broadcast/transactional sends |
|
package/dist/types/comms.d.ts
CHANGED
|
@@ -275,4 +275,68 @@ export interface SubscriptionsResolveResponse {
|
|
|
275
275
|
anyMethods: boolean;
|
|
276
276
|
anyWalletForSubject: boolean;
|
|
277
277
|
}
|
|
278
|
+
/**
|
|
279
|
+
* Send a single message to one contact using a template.
|
|
280
|
+
* No broadcast record is created; the send is logged directly to the
|
|
281
|
+
* contact's communication history with sourceType: 'transactional'.
|
|
282
|
+
*
|
|
283
|
+
* POST /admin/collection/:collectionId/comm/send
|
|
284
|
+
*/
|
|
285
|
+
export interface TransactionalSendRequest {
|
|
286
|
+
/** CRM contact UUID */
|
|
287
|
+
contactId: string;
|
|
288
|
+
/** Firestore template ID */
|
|
289
|
+
templateId: string;
|
|
290
|
+
/**
|
|
291
|
+
* Channel to send on. Defaults to 'preferred', which auto-selects the
|
|
292
|
+
* contact's best available channel respecting consent, suppression, and
|
|
293
|
+
* template availability (priority: email → push → sms → wallet).
|
|
294
|
+
*/
|
|
295
|
+
channel?: 'email' | 'sms' | 'push' | 'wallet' | 'preferred';
|
|
296
|
+
/** Extra Liquid variables merged into the top-level render context */
|
|
297
|
+
props?: Record<string, unknown>;
|
|
298
|
+
/** Context objects to hydrate into the Liquid template */
|
|
299
|
+
include?: {
|
|
300
|
+
/** Hydrate {{ collection }}. Default: true */
|
|
301
|
+
collection?: boolean;
|
|
302
|
+
/** Hydrate {{ product }} from this product ID */
|
|
303
|
+
productId?: string;
|
|
304
|
+
/** Hydrate {{ proof }} (requires productId) */
|
|
305
|
+
proofId?: string;
|
|
306
|
+
/** Hydrate {{ user }} from contact.userId */
|
|
307
|
+
user?: boolean;
|
|
308
|
+
/** Hydrate {{ appCase }} from this case UUID */
|
|
309
|
+
appCase?: string;
|
|
310
|
+
/** Hydrate {{ appThread }} from this thread UUID */
|
|
311
|
+
appThread?: string;
|
|
312
|
+
/** Hydrate {{ appRecord }} from this record UUID */
|
|
313
|
+
appRecord?: string;
|
|
314
|
+
};
|
|
315
|
+
/** Arbitrary label stored on the comms-events row (e.g. 'warranty-step-2') */
|
|
316
|
+
ref?: string;
|
|
317
|
+
/** App context stored on the comms-events row */
|
|
318
|
+
appId?: string;
|
|
319
|
+
}
|
|
320
|
+
export interface TransactionalSendResponse {
|
|
321
|
+
ok: true;
|
|
322
|
+
/** The channel the message was actually sent on */
|
|
323
|
+
channel: 'email' | 'sms' | 'push' | 'wallet';
|
|
324
|
+
/** Provider message ID (email/SMS); absent for push/wallet */
|
|
325
|
+
messageId?: string;
|
|
326
|
+
}
|
|
327
|
+
export interface TransactionalSendError {
|
|
328
|
+
ok: false;
|
|
329
|
+
/**
|
|
330
|
+
* Error code. Known values:
|
|
331
|
+
* - `transactional.contact_not_found`
|
|
332
|
+
* - `transactional.template_not_found`
|
|
333
|
+
* - `transactional.no_channel_available`
|
|
334
|
+
* - `transactional.email_missing`
|
|
335
|
+
* - `transactional.phone_missing`
|
|
336
|
+
* - `transactional.no_push_methods`
|
|
337
|
+
* - `transactional.no_wallet_methods`
|
|
338
|
+
*/
|
|
339
|
+
error: string;
|
|
340
|
+
}
|
|
341
|
+
export type TransactionalSendResult = TransactionalSendResponse | TransactionalSendError;
|
|
278
342
|
export {};
|