@proveanything/smartlinks 1.8.10 → 1.8.12
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/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/api/interactions.d.ts +10 -1
- package/dist/api/interactions.js +16 -0
- package/dist/api/loyalty.d.ts +65 -0
- package/dist/api/loyalty.js +139 -0
- package/dist/docs/API_SUMMARY.md +308 -1
- package/dist/docs/loyalty.md +333 -0
- package/dist/openapi.yaml +1151 -155
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/interaction.d.ts +34 -0
- package/dist/types/loyalty.d.ts +145 -0
- package/dist/types/loyalty.js +2 -0
- package/docs/API_SUMMARY.md +308 -1
- package/docs/loyalty.md +333 -0
- package/openapi.yaml +1151 -155
- package/package.json +1 -1
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
# Loyalty: Points, Members & Earning Rules
|
|
2
|
+
|
|
3
|
+
The `loyalty` namespace lets you build loyalty programmes on top of any collection. Contacts earn points for doing things — registering products, voting, entering competitions, engaging with content — and you track their balance, lifetime points, and full transaction history.
|
|
4
|
+
|
|
5
|
+
Loyalty is designed to be **decoupled from your app logic**. You configure earning rules that say "this interaction earns N points under these conditions", and the platform awards points automatically whenever that interaction event fires. Your app just logs interactions as normal.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Core Concepts
|
|
10
|
+
|
|
11
|
+
| Concept | Description |
|
|
12
|
+
|---------|-------------|
|
|
13
|
+
| **Scheme** | A named loyalty programme within a collection (e.g. "Brand Rewards"). A collection can have multiple schemes. |
|
|
14
|
+
| **Member** | A contact's enrolment in a scheme. Created automatically the first time they earn points. Holds their current `balance` and `lifetimePoints`. |
|
|
15
|
+
| **Transaction** | An append-only ledger entry. Signed integer — positive = earn, negative = spend/deduct. Every point change goes through a transaction. |
|
|
16
|
+
| **Earning Rule** | Links an Interaction definition to a point award. When an interaction event fires that matches the rule's conditions, points are awarded automatically. |
|
|
17
|
+
|
|
18
|
+
```text
|
|
19
|
+
┌─────────────────────────────────────────────────────────────────────┐
|
|
20
|
+
│ User registers a product │
|
|
21
|
+
│ ↓ │
|
|
22
|
+
│ interactions.appendEvent(collectionId, { │
|
|
23
|
+
│ interactionId: 'product-registration', │
|
|
24
|
+
│ outcome: 'activated', │
|
|
25
|
+
│ contactId: '...' │
|
|
26
|
+
│ }) │
|
|
27
|
+
│ ↓ │
|
|
28
|
+
│ Platform evaluates earning rules for 'product-registration' │
|
|
29
|
+
│ ↓ │
|
|
30
|
+
│ Rule matches → contact awarded 50 points automatically │
|
|
31
|
+
│ ↓ │
|
|
32
|
+
│ loyalty.publicGetMe(collectionId) → balance: 50 │
|
|
33
|
+
└─────────────────────────────────────────────────────────────────────┘
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Schemes
|
|
39
|
+
|
|
40
|
+
### Create a Scheme
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
const scheme = await SL.loyalty.create(collectionId, {
|
|
44
|
+
name: 'Brand Rewards',
|
|
45
|
+
type: 'points',
|
|
46
|
+
data: {
|
|
47
|
+
pointName: 'Star', // Display name for one point
|
|
48
|
+
pointNamePlural: 'Stars',
|
|
49
|
+
currencySymbol: '⭐',
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### List & Get
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
// Admin: all schemes including inactive/deleted
|
|
58
|
+
const schemes = await SL.loyalty.list(collectionId);
|
|
59
|
+
const scheme = await SL.loyalty.get(collectionId, schemeId);
|
|
60
|
+
|
|
61
|
+
// Public: active schemes only (no admin/owner data blocks)
|
|
62
|
+
const schemes = await SL.loyalty.publicList(collectionId);
|
|
63
|
+
const scheme = await SL.loyalty.publicGet(collectionId, schemeId);
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Update & Delete
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
await SL.loyalty.update(collectionId, schemeId, { active: false });
|
|
70
|
+
await SL.loyalty.remove(collectionId, schemeId); // soft-delete
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Getting a User's Loyalty Status
|
|
76
|
+
|
|
77
|
+
### The one-call pattern
|
|
78
|
+
|
|
79
|
+
`publicGetMe` is the primary entry point for any loyalty UI. It returns all active schemes with the caller's membership embedded — one request, everything you need.
|
|
80
|
+
|
|
81
|
+
```typescript
|
|
82
|
+
const schemes = await SL.loyalty.publicGetMe(collectionId);
|
|
83
|
+
|
|
84
|
+
// schemes[0] looks like:
|
|
85
|
+
// {
|
|
86
|
+
// id: 'abc-123',
|
|
87
|
+
// name: 'Brand Rewards',
|
|
88
|
+
// type: 'points',
|
|
89
|
+
// data: { pointName: 'Star', ... },
|
|
90
|
+
// member: {
|
|
91
|
+
// balance: 350,
|
|
92
|
+
// lifetimePoints: 820,
|
|
93
|
+
// contactId: '...',
|
|
94
|
+
// createdAt: '...',
|
|
95
|
+
// ...
|
|
96
|
+
// }
|
|
97
|
+
// }
|
|
98
|
+
//
|
|
99
|
+
// member is null if the user hasn't earned any points yet in that scheme
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Unauthenticated callers get `member: null` on every scheme — useful for previewing the programme before sign-in.
|
|
103
|
+
|
|
104
|
+
### Scheme-specific membership
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
// Current balance + lifetime points
|
|
108
|
+
const member = await SL.loyalty.publicGetMine(collectionId, schemeId);
|
|
109
|
+
|
|
110
|
+
// Transaction history (newest first, paginated)
|
|
111
|
+
const { items } = await SL.loyalty.publicGetMineHistory(collectionId, schemeId, {
|
|
112
|
+
limit: 20,
|
|
113
|
+
offset: 0,
|
|
114
|
+
});
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## Earning Rules
|
|
120
|
+
|
|
121
|
+
Earning rules are the control plane that connects interactions to point awards. The server evaluates them automatically — your app never sets a point value directly.
|
|
122
|
+
|
|
123
|
+
### Create a Rule
|
|
124
|
+
|
|
125
|
+
```typescript
|
|
126
|
+
// Award 50 points when a product is registered
|
|
127
|
+
await SL.loyalty.createEarningRule(collectionId, schemeId, {
|
|
128
|
+
interactionId: 'product-registration',
|
|
129
|
+
points: 50,
|
|
130
|
+
conditions: { outcome: 'activated' }, // only fires on this outcome
|
|
131
|
+
maxPerContact: 1, // once per contact lifetime
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Award 1 point for any vote, no limit, 24-hour cooldown
|
|
135
|
+
await SL.loyalty.createEarningRule(collectionId, schemeId, {
|
|
136
|
+
interactionId: 'competition-vote',
|
|
137
|
+
points: 1,
|
|
138
|
+
cooldownHours: 24,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Award 10 points for winning — conditions match event fields
|
|
142
|
+
await SL.loyalty.createEarningRule(collectionId, schemeId, {
|
|
143
|
+
interactionId: 'spin-to-win',
|
|
144
|
+
points: 10,
|
|
145
|
+
conditions: { outcome: 'win' },
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Award 2 points for entering — same interaction, different rule
|
|
149
|
+
await SL.loyalty.createEarningRule(collectionId, schemeId, {
|
|
150
|
+
interactionId: 'spin-to-win',
|
|
151
|
+
points: 2,
|
|
152
|
+
conditions: { outcome: 'entered' },
|
|
153
|
+
cooldownHours: 24,
|
|
154
|
+
});
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Multiple rules can reference the same `interactionId`. All matching rules fire — so an event can earn points from several rules simultaneously if you design them that way.
|
|
158
|
+
|
|
159
|
+
### Conditions
|
|
160
|
+
|
|
161
|
+
Conditions are key-value pairs matched against the interaction event before a rule fires. An empty `conditions` object means the rule always fires for any event on that interaction.
|
|
162
|
+
|
|
163
|
+
| Condition key | What it matches |
|
|
164
|
+
|---------------|-----------------|
|
|
165
|
+
| `outcome` | `event.outcome` |
|
|
166
|
+
| `scope` | `event.scope` |
|
|
167
|
+
| `status` | `event.status` |
|
|
168
|
+
| `eventType` | `event.eventType` |
|
|
169
|
+
| `metadata.tier` | `event.metadata.tier` |
|
|
170
|
+
| `metadata.region` | `event.metadata.region` |
|
|
171
|
+
|
|
172
|
+
```typescript
|
|
173
|
+
// Only award points to gold-tier members in UK events
|
|
174
|
+
await SL.loyalty.createEarningRule(collectionId, schemeId, {
|
|
175
|
+
interactionId: 'competition-entry',
|
|
176
|
+
points: 5,
|
|
177
|
+
conditions: {
|
|
178
|
+
outcome: 'entered',
|
|
179
|
+
'metadata.tier': 'gold',
|
|
180
|
+
'metadata.region': 'UK',
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Rate Limiting
|
|
186
|
+
|
|
187
|
+
| Field | Effect |
|
|
188
|
+
|-------|--------|
|
|
189
|
+
| `maxPerContact` | Caps the total lifetime number of times this rule can fire per contact. `null` = unlimited. |
|
|
190
|
+
| `cooldownHours` | Minimum gap between triggers for the same contact. `null` = no cooldown. |
|
|
191
|
+
|
|
192
|
+
### List, Update & Delete Rules
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
// List rules (admin)
|
|
196
|
+
const rules = await SL.loyalty.listEarningRules(collectionId, schemeId);
|
|
197
|
+
|
|
198
|
+
// Public: show "how to earn" in UI (active rules only)
|
|
199
|
+
const rules = await SL.loyalty.publicListEarningRules(collectionId, schemeId);
|
|
200
|
+
|
|
201
|
+
// Update
|
|
202
|
+
await SL.loyalty.updateEarningRule(collectionId, schemeId, ruleId, {
|
|
203
|
+
points: 75,
|
|
204
|
+
active: false,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// Delete
|
|
208
|
+
await SL.loyalty.removeEarningRule(collectionId, schemeId, ruleId);
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
## Manual Transactions (Admin)
|
|
214
|
+
|
|
215
|
+
For welcome bonuses, corrections, or admin overrides — points that aren't tied to an interaction event.
|
|
216
|
+
|
|
217
|
+
```typescript
|
|
218
|
+
// Award a welcome bonus
|
|
219
|
+
await SL.loyalty.recordTransaction(collectionId, schemeId, contactId, {
|
|
220
|
+
points: 100,
|
|
221
|
+
reason: 'Welcome bonus',
|
|
222
|
+
idempotencyKey: `welcome-${contactId}`, // prevents double-award on retry
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// Deduct points (spend)
|
|
226
|
+
await SL.loyalty.recordTransaction(collectionId, schemeId, contactId, {
|
|
227
|
+
points: -50,
|
|
228
|
+
reason: 'Redeemed for discount',
|
|
229
|
+
metadata: { redemptionRef: 'DISC-2026-0042' },
|
|
230
|
+
});
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Deducting below zero returns HTTP 422 `INSUFFICIENT_BALANCE`. The transaction is rejected — the balance is never taken negative.
|
|
234
|
+
|
|
235
|
+
### Idempotency
|
|
236
|
+
|
|
237
|
+
Supply `idempotencyKey` whenever there is any risk of the request being retried (network failures, client retries, webhook replays). The key is scoped per scheme — reusing it on a different scheme is fine.
|
|
238
|
+
|
|
239
|
+
```typescript
|
|
240
|
+
await SL.loyalty.recordTransaction(collectionId, schemeId, contactId, {
|
|
241
|
+
points: 50,
|
|
242
|
+
reason: 'Referral reward',
|
|
243
|
+
idempotencyKey: `referral-${referrerId}-${refereeId}`,
|
|
244
|
+
});
|
|
245
|
+
// Calling again with the same key returns 409 — no duplicate transaction created
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### Viewing Member History (Admin)
|
|
249
|
+
|
|
250
|
+
```typescript
|
|
251
|
+
// All members in a scheme
|
|
252
|
+
const { items } = await SL.loyalty.listMembers(collectionId, schemeId, { limit: 50 });
|
|
253
|
+
|
|
254
|
+
// A specific member
|
|
255
|
+
const member = await SL.loyalty.getMember(collectionId, schemeId, contactId);
|
|
256
|
+
|
|
257
|
+
// Full transaction history for a member
|
|
258
|
+
const { items } = await SL.loyalty.getMemberHistory(collectionId, schemeId, contactId, {
|
|
259
|
+
limit: 100,
|
|
260
|
+
offset: 0,
|
|
261
|
+
});
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
## balance vs lifetimePoints
|
|
267
|
+
|
|
268
|
+
| Field | What it tracks | Can it decrease? |
|
|
269
|
+
|-------|---------------|-----------------|
|
|
270
|
+
| `balance` | Current redeemable points | Yes — deducted on spend |
|
|
271
|
+
| `lifetimePoints` | Total ever earned | Never — only increases |
|
|
272
|
+
|
|
273
|
+
Use `lifetimePoints` for tier calculations (Bronze / Silver / Gold). Use `balance` for showing what a contact can currently spend.
|
|
274
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
## Integration with Interactions
|
|
278
|
+
|
|
279
|
+
Loyalty earning is a side-effect of the interaction system. Your app calls `interactions.appendEvent` exactly as it would without loyalty — the platform handles the rest.
|
|
280
|
+
|
|
281
|
+
```typescript
|
|
282
|
+
// Your app logs an interaction event as normal
|
|
283
|
+
await SL.interactions.appendEvent(collectionId, {
|
|
284
|
+
appId: 'my-app',
|
|
285
|
+
interactionId: 'product-registration',
|
|
286
|
+
outcome: 'activated',
|
|
287
|
+
contactId: contact.contactId,
|
|
288
|
+
productId: product.id,
|
|
289
|
+
metadata: { serialNumber: 'SN-12345' },
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// Behind the scenes (no app code needed):
|
|
293
|
+
// 1. Event is logged to BigQuery
|
|
294
|
+
// 2. Platform checks earning rules for 'product-registration'
|
|
295
|
+
// 3. Matching rules fire → contact's balance updated atomically
|
|
296
|
+
// 4. Transaction appended to ledger with ruleId in metadata
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
A loyalty failure never causes the interaction event to fail — errors are caught and logged server-side so your app always gets a clean response.
|
|
300
|
+
|
|
301
|
+
---
|
|
302
|
+
|
|
303
|
+
## TypeScript Types
|
|
304
|
+
|
|
305
|
+
```typescript
|
|
306
|
+
import type {
|
|
307
|
+
LoyaltyScheme, // Scheme definition
|
|
308
|
+
LoyaltyMember, // Contact's enrolment + balance
|
|
309
|
+
LoyaltyTransaction, // Single ledger entry
|
|
310
|
+
LoyaltyEarningRule, // Interaction → points mapping
|
|
311
|
+
LoyaltySchemeWithMembership, // publicGetMe() response shape
|
|
312
|
+
LoyaltyTransactionResult, // recordTransaction() response
|
|
313
|
+
LoyaltyPaginatedResult, // { items, limit, offset }
|
|
314
|
+
LoyaltyPaginationParams, // { limit?, offset? }
|
|
315
|
+
CreateLoyaltySchemeBody,
|
|
316
|
+
UpdateLoyaltySchemeBody,
|
|
317
|
+
CreateLoyaltyEarningRuleBody,
|
|
318
|
+
UpdateLoyaltyEarningRuleBody,
|
|
319
|
+
RecordLoyaltyTransactionBody,
|
|
320
|
+
} from '@proveanything/smartlinks';
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
---
|
|
324
|
+
|
|
325
|
+
## Best Practices
|
|
326
|
+
|
|
327
|
+
- Use `publicGetMe` as your single loyalty widget data source — one call, all schemes, membership embedded
|
|
328
|
+
- Always supply `idempotencyKey` on manual transactions that could be retried
|
|
329
|
+
- Use `maxPerContact: 1` on one-time actions (product registration, sign-up bonus)
|
|
330
|
+
- Use `cooldownHours` on repeating actions to prevent gaming (daily check-ins, votes)
|
|
331
|
+
- Store display config (`pointName`, `currencySymbol`, tier thresholds) in the scheme's `data` block — keeps the UI configurable without code changes
|
|
332
|
+
- Use `lifetimePoints` for tier logic, `balance` for spend eligibility
|
|
333
|
+
- Let interactions do the validation — don't award points via `recordTransaction` for events that should go through an earning rule; let the server be the arbiter of point values
|