@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.
@@ -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