@proveanything/smartlinks 1.11.6 → 1.11.8
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/interactions.d.ts +8 -5
- package/dist/api/interactions.js +6 -4
- package/dist/docs/API_SUMMARY.md +40 -12
- package/dist/docs/app-manifest.md +2 -2
- package/dist/docs/app-objects.md +2 -2
- package/{docs/records-admin-pattern.md → dist/docs/app-records-pattern.md} +26 -2
- package/dist/docs/interactions.md +26 -3
- package/dist/docs/overview.md +1 -1
- package/dist/docs/ui-utils.md +3 -3
- package/dist/openapi.yaml +58 -0
- package/dist/types/interaction.d.ts +15 -0
- package/dist/utils/anonId.d.ts +27 -0
- package/dist/utils/anonId.js +38 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +1 -0
- package/docs/API_SUMMARY.md +40 -12
- package/docs/app-manifest.md +2 -2
- package/docs/app-objects.md +2 -2
- package/docs/app-records-pattern.md +329 -0
- package/docs/interactions.md +26 -3
- package/docs/overview.md +1 -1
- package/docs/ui-utils.md +3 -3
- package/openapi.yaml +58 -0
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AdminInteractionsCountsByOutcomeRequest, AdminInteractionsQueryRequest, AdminInteractionsAggregateRequest, AdminInteractionsAggregateResponse, AppendInteractionBody, UpdateInteractionBody, OutcomeCount, InteractionEventRow, PublicInteractionsCountsByOutcomeRequest, PublicInteractionsByUserRequest, CreateInteractionTypeBody, UpdateInteractionTypeBody, ListInteractionTypesQuery, InteractionTypeRecord, InteractionTypeList } from "../types/interaction";
|
|
1
|
+
import type { AdminInteractionsCountsByOutcomeRequest, AdminInteractionsQueryRequest, AdminInteractionsAggregateRequest, AdminInteractionsAggregateResponse, AppendInteractionBody, UpdateInteractionBody, OutcomeCount, InteractionEventRow, PublicInteractionsCountsByOutcomeRequest, PublicInteractionsByUserRequest, SubmitInteractionResponse, SubmitInteractionError, CreateInteractionTypeBody, UpdateInteractionTypeBody, ListInteractionTypesQuery, InteractionTypeRecord, InteractionTypeList } from "../types/interaction";
|
|
2
2
|
export declare namespace interactions {
|
|
3
3
|
/**
|
|
4
4
|
* POST /admin/collection/:collectionId/interactions/query
|
|
@@ -30,11 +30,14 @@ export declare namespace interactions {
|
|
|
30
30
|
success: true;
|
|
31
31
|
}>;
|
|
32
32
|
/**
|
|
33
|
-
*
|
|
33
|
+
* POST /api/v1/public/collection/:collectionId/interactions/submit
|
|
34
|
+
*
|
|
35
|
+
* Submits an interaction event from a public/client-side context.
|
|
36
|
+
* When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor
|
|
37
|
+
* `contactId` is required. Pass `anonId` inside `metadata` to enable
|
|
38
|
+
* device-level deduplication via `uniquePerAnonId`.
|
|
34
39
|
*/
|
|
35
|
-
function submitPublicEvent(collectionId: string, body: AppendInteractionBody): Promise<
|
|
36
|
-
success: true;
|
|
37
|
-
}>;
|
|
40
|
+
function submitPublicEvent(collectionId: string, body: AppendInteractionBody): Promise<SubmitInteractionResponse | SubmitInteractionError>;
|
|
38
41
|
function create(collectionId: string, body: CreateInteractionTypeBody): Promise<InteractionTypeRecord>;
|
|
39
42
|
function list(collectionId: string, query?: ListInteractionTypesQuery): Promise<InteractionTypeList>;
|
|
40
43
|
function get(collectionId: string, id: string): Promise<InteractionTypeRecord>;
|
package/dist/api/interactions.js
CHANGED
|
@@ -73,12 +73,14 @@ export var interactions;
|
|
|
73
73
|
}
|
|
74
74
|
interactions.updateEvent = updateEvent;
|
|
75
75
|
/**
|
|
76
|
-
*
|
|
76
|
+
* POST /api/v1/public/collection/:collectionId/interactions/submit
|
|
77
|
+
*
|
|
78
|
+
* Submits an interaction event from a public/client-side context.
|
|
79
|
+
* When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor
|
|
80
|
+
* `contactId` is required. Pass `anonId` inside `metadata` to enable
|
|
81
|
+
* device-level deduplication via `uniquePerAnonId`.
|
|
77
82
|
*/
|
|
78
83
|
async function submitPublicEvent(collectionId, body) {
|
|
79
|
-
if (!body.userId && !body.contactId) {
|
|
80
|
-
throw new Error("AppendInteractionBody must include one of userId or contactId");
|
|
81
|
-
}
|
|
82
84
|
const path = `/public/collection/${encodeURIComponent(collectionId)}/interactions/submit`;
|
|
83
85
|
return post(path, body);
|
|
84
86
|
}
|
package/dist/docs/API_SUMMARY.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Smartlinks API Summary
|
|
2
2
|
|
|
3
|
-
Version: 1.11.
|
|
3
|
+
Version: 1.11.8 | Generated: 2026-05-03T08:12:27.170Z
|
|
4
4
|
|
|
5
5
|
This is a concise summary of all available API functions and types.
|
|
6
6
|
|
|
@@ -5153,6 +5153,10 @@ interface InteractionPermissions {
|
|
|
5153
5153
|
* Authenticated summary visibility (counts, aggregates) when user is signed in.
|
|
5154
5154
|
allowAuthenticatedSummary?: boolean
|
|
5155
5155
|
allowOwnRead?: boolean
|
|
5156
|
+
uniquePerAnonId?: boolean
|
|
5157
|
+
* Time window in seconds for `uniquePerAnonId` enforcement.
|
|
5158
|
+
* `0` or omitted means all-time deduplication.
|
|
5159
|
+
uniquePerAnonIdWindowSeconds?: number
|
|
5156
5160
|
}
|
|
5157
5161
|
```
|
|
5158
5162
|
|
|
@@ -5220,6 +5224,30 @@ interface ListInteractionTypesQuery {
|
|
|
5220
5224
|
}
|
|
5221
5225
|
```
|
|
5222
5226
|
|
|
5227
|
+
**SubmitInteractionResponse** (interface)
|
|
5228
|
+
```typescript
|
|
5229
|
+
interface SubmitInteractionResponse {
|
|
5230
|
+
success: true
|
|
5231
|
+
eventId: string
|
|
5232
|
+
}
|
|
5233
|
+
```
|
|
5234
|
+
|
|
5235
|
+
**SubmitInteractionError** (interface)
|
|
5236
|
+
```typescript
|
|
5237
|
+
interface SubmitInteractionError {
|
|
5238
|
+
error: 'FORBIDDEN'
|
|
5239
|
+
reason:
|
|
5240
|
+
| 'not_public'
|
|
5241
|
+
| 'auth_required'
|
|
5242
|
+
| 'duplicate'
|
|
5243
|
+
| 'duplicate_anon'
|
|
5244
|
+
| 'disabled'
|
|
5245
|
+
| 'before_start'
|
|
5246
|
+
| 'after_end'
|
|
5247
|
+
| 'origin_forbidden'
|
|
5248
|
+
}
|
|
5249
|
+
```
|
|
5250
|
+
|
|
5223
5251
|
### jobs
|
|
5224
5252
|
|
|
5225
5253
|
**Job** (interface)
|
|
@@ -8333,47 +8361,47 @@ POST /admin/collection/:collectionId/interactions/append Appends one interaction
|
|
|
8333
8361
|
POST /admin/collection/:collectionId/interactions/append Appends one interaction event.
|
|
8334
8362
|
|
|
8335
8363
|
**submitPublicEvent**(collectionId: string,
|
|
8336
|
-
body: AppendInteractionBody) → `Promise
|
|
8337
|
-
|
|
8364
|
+
body: AppendInteractionBody) → `Promise<SubmitInteractionResponse | SubmitInteractionError>`
|
|
8365
|
+
POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context. When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor `contactId` is required. Pass `anonId` inside `metadata` to enable device-level deduplication via `uniquePerAnonId`.
|
|
8338
8366
|
|
|
8339
8367
|
**create**(collectionId: string,
|
|
8340
8368
|
body: CreateInteractionTypeBody) → `Promise<InteractionTypeRecord>`
|
|
8341
|
-
|
|
8369
|
+
POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context. When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor `contactId` is required. Pass `anonId` inside `metadata` to enable device-level deduplication via `uniquePerAnonId`.
|
|
8342
8370
|
|
|
8343
8371
|
**list**(collectionId: string,
|
|
8344
8372
|
query: ListInteractionTypesQuery = {}) → `Promise<InteractionTypeList>`
|
|
8345
|
-
|
|
8373
|
+
POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context. When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor `contactId` is required. Pass `anonId` inside `metadata` to enable device-level deduplication via `uniquePerAnonId`.
|
|
8346
8374
|
|
|
8347
8375
|
**get**(collectionId: string,
|
|
8348
8376
|
id: string) → `Promise<InteractionTypeRecord>`
|
|
8349
|
-
|
|
8377
|
+
POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context. When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor `contactId` is required. Pass `anonId` inside `metadata` to enable device-level deduplication via `uniquePerAnonId`.
|
|
8350
8378
|
|
|
8351
8379
|
**update**(collectionId: string,
|
|
8352
8380
|
id: string,
|
|
8353
8381
|
patchBody: UpdateInteractionTypeBody) → `Promise<InteractionTypeRecord>`
|
|
8354
|
-
|
|
8382
|
+
POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context. When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor `contactId` is required. Pass `anonId` inside `metadata` to enable device-level deduplication via `uniquePerAnonId`.
|
|
8355
8383
|
|
|
8356
8384
|
**remove**(collectionId: string,
|
|
8357
8385
|
id: string) → `Promise<void>`
|
|
8358
|
-
|
|
8386
|
+
POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context. When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor `contactId` is required. Pass `anonId` inside `metadata` to enable device-level deduplication via `uniquePerAnonId`.
|
|
8359
8387
|
|
|
8360
8388
|
**publicCountsByOutcome**(collectionId: string,
|
|
8361
8389
|
body: PublicInteractionsCountsByOutcomeRequest,
|
|
8362
8390
|
authToken?: string) → `Promise<OutcomeCount[]>`
|
|
8363
|
-
|
|
8391
|
+
POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context. When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor `contactId` is required. Pass `anonId` inside `metadata` to enable device-level deduplication via `uniquePerAnonId`.
|
|
8364
8392
|
|
|
8365
8393
|
**publicMyInteractions**(collectionId: string,
|
|
8366
8394
|
body: PublicInteractionsByUserRequest,
|
|
8367
8395
|
authToken?: string) → `Promise<InteractionEventRow[]>`
|
|
8368
|
-
|
|
8396
|
+
POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context. When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor `contactId` is required. Pass `anonId` inside `metadata` to enable device-level deduplication via `uniquePerAnonId`.
|
|
8369
8397
|
|
|
8370
8398
|
**publicList**(collectionId: string,
|
|
8371
8399
|
query: ListInteractionTypesQuery = {}) → `Promise<InteractionTypeList>`
|
|
8372
|
-
|
|
8400
|
+
POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context. When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor `contactId` is required. Pass `anonId` inside `metadata` to enable device-level deduplication via `uniquePerAnonId`.
|
|
8373
8401
|
|
|
8374
8402
|
**publicGet**(collectionId: string,
|
|
8375
8403
|
id: string) → `Promise<InteractionTypeRecord>`
|
|
8376
|
-
|
|
8404
|
+
POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context. When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor `contactId` is required. Pass `anonId` inside `metadata` to enable device-level deduplication via `uniquePerAnonId`.
|
|
8377
8405
|
|
|
8378
8406
|
### jobs
|
|
8379
8407
|
|
|
@@ -270,7 +270,7 @@ See the [Deep Link Discovery guide](deep-link-discovery.md) for the full dual-so
|
|
|
270
270
|
|
|
271
271
|
#### `records`
|
|
272
272
|
|
|
273
|
-
Declares which `app.records` record types the app stores, and which scopes each type supports. Required for any app that follows the [Records
|
|
273
|
+
Declares which `app.records` record types the app stores, and which scopes each type supports. Required for any app that follows the [App Records Pattern](app-records-pattern.md). Omit if the app does not use scoped records.
|
|
274
274
|
|
|
275
275
|
The platform and the `<RecordsAdminShell>` from `@proveanything/smartlinks-utils-ui` read this block to render the right scope tabs, rule editor, and cardinality-appropriate right pane.
|
|
276
276
|
|
|
@@ -294,7 +294,7 @@ The platform and the `<RecordsAdminShell>` from `@proveanything/smartlinks-utils
|
|
|
294
294
|
| `scopes` | string[] | — | Allowed scope kinds in resolution order. Valid values: `"collection"`, `"product"`, `"variant"`, `"batch"`, `"facet"`, `"proof"`, `"rule"`. `'rule'` is a synthetic scope holding `facetRule`-targeted records. `'collection'` replaces the legacy empty-ref catch-all — **there is no `'global'` scope**. |
|
|
295
295
|
| `defaultScope` | string | — | The scope the "Create new" button targets in the admin shell. Must be one of the declared `scopes`. |
|
|
296
296
|
|
|
297
|
-
An app may declare multiple record types under different keys (e.g. `"nutrition"` and `"cooking_steps"`). See [records-
|
|
297
|
+
An app may declare multiple record types under different keys (e.g. `"nutrition"` and `"cooking_steps"`). See [app-records-pattern.md](app-records-pattern.md) for the full admin + public pattern.
|
|
298
298
|
|
|
299
299
|
#### `executor`
|
|
300
300
|
|
package/dist/docs/app-objects.md
CHANGED
|
@@ -445,7 +445,7 @@ const productComments = await app.threads.list(collectionId, appId, {
|
|
|
445
445
|
- **Usage logs** — record product usage metrics
|
|
446
446
|
- **Audit trails** — immutable logs of actions
|
|
447
447
|
- **Loyalty points** — track points earned/redeemed
|
|
448
|
-
- **Per-product / per-facet configuration** — scoped data that varies by product axis (see [records-
|
|
448
|
+
- **Per-product / per-facet configuration** — scoped data that varies by product axis (see [app-records-pattern.md](app-records-pattern.md))
|
|
449
449
|
|
|
450
450
|
### Key Features
|
|
451
451
|
|
|
@@ -526,7 +526,7 @@ proof → batch → variant → product → rule(*) → facet(*) →
|
|
|
526
526
|
- `facet(*)` — legacy single-facet anchors, walked alphabetically. Prefer `facetRule` for new work.
|
|
527
527
|
- `collection` — the top of the chain and the catch-all for any record with no anchor fields. **There is no `'global'` tier above collection.**
|
|
528
528
|
|
|
529
|
-
For a **singleton** record type (one answer wins), use `useResolvedRecord` — it performs this walk server-side and returns the first match plus a `matchedAt` tag. For a **collection** record type (every match is shown), use `useCollectedRecords`. See [records-
|
|
529
|
+
For a **singleton** record type (one answer wins), use `useResolvedRecord` — it performs this walk server-side and returns the first match plus a `matchedAt` tag. For a **collection** record type (every match is shown), use `useCollectedRecords`. See [app-records-pattern.md §2](app-records-pattern.md#2-resolution-order-one-canonical-chain) for the full guide.
|
|
530
530
|
|
|
531
531
|
### Singleton Cardinality
|
|
532
532
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# SmartLinks Records
|
|
1
|
+
# SmartLinks App Records Pattern
|
|
2
2
|
|
|
3
3
|
> Canonical guide for microapps that store **per-product**, **per-facet**, **per-variant**, **per-batch**, or **rule-targeted** data.
|
|
4
4
|
>
|
|
@@ -172,6 +172,26 @@ import { FacetRuleEditor } from '@proveanything/smartlinks-utils-ui/facet-rule-e
|
|
|
172
172
|
|
|
173
173
|
## 5. Public side — pick the right hook (this is where apps go wrong)
|
|
174
174
|
|
|
175
|
+
> **Admin vs public — the rule is simple:**
|
|
176
|
+
>
|
|
177
|
+
> | Function | Public widget | Admin dashboard |
|
|
178
|
+
> |---|---|---|
|
|
179
|
+
> | `app.records.create(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
|
|
180
|
+
> | `app.records.list(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
|
|
181
|
+
> | `app.records.get(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
|
|
182
|
+
> | `app.records.update(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
|
|
183
|
+
> | `app.records.remove(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
|
|
184
|
+
> | `app.records.aggregate(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
|
|
185
|
+
> | `app.records.match(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
|
|
186
|
+
> | `app.records.resolveAll(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
|
|
187
|
+
> | `app.records.upsert()` | ❌ admin only — no public path | ✅ |
|
|
188
|
+
> | `app.records.bulkUpsert()` | ❌ admin only — no public path | ✅ |
|
|
189
|
+
> | `app.records.bulkDelete()` | ❌ admin only — no public path | ✅ |
|
|
190
|
+
> | `app.records.restore()` | ❌ admin only — no public path | ✅ |
|
|
191
|
+
> | `app.records.previewRule()` | ❌ admin only — no public path | ✅ |
|
|
192
|
+
>
|
|
193
|
+
> The `admin` parameter on `match()` and `resolveAll()` defaults to `false`, so public widgets that omit it are fine. **Never hardcode `true` in a public widget.** The hooks below always use the public path and are the recommended approach — prefer them over raw SDK calls in widget code.
|
|
194
|
+
|
|
175
195
|
There is exactly **one decision** to make on the public side, and it follows from the manifest's `cardinality`:
|
|
176
196
|
|
|
177
197
|
### 5a. Singleton → `useResolvedRecord` (best match wins)
|
|
@@ -236,6 +256,10 @@ const { entries, isLoading } = useResolveAllRecords({
|
|
|
236
256
|
| ❌ Anti-pattern | ✅ Do this instead |
|
|
237
257
|
| ---------------------------------------------------------- | ----------------------------------------------------------------- |
|
|
238
258
|
| Calling `SL.app.records.list()` and filtering client-side | `useResolvedRecord` (singleton) or `useCollectedRecords` (collection). The server already knows the chain. |
|
|
259
|
+
| Calling `SL.app.records.list(…, true)` from a public widget | Omit the `admin` flag (defaults to `false`). `list()` has a public path — just don't pass `true`. |
|
|
260
|
+
| Calling `SL.app.records.match(…, true)` from a public widget | Omit the `admin` flag (defaults to `false`) or use `useResolvedRecord` / `useCollectedRecords`. |
|
|
261
|
+
| Calling `SL.app.records.resolveAll(…, true)` from a public widget | Omit the `admin` flag (defaults to `false`) or use `useResolveAllRecords`. |
|
|
262
|
+
| Calling `upsert`, `bulkUpsert`, `bulkDelete`, or `previewRule` from widget code | Those are admin-only. Widget code reads data — it never writes records directly. |
|
|
239
263
|
| Walking the chain by hand with multiple `getConfig` calls | One hook call. The resolver is tested, cached, and includes rules. |
|
|
240
264
|
| Treating `facet:key:value` refs as the rule mechanism | Use `facetRule` (`{ all: [{ facetKey, anyOf: [...] }] }`). Multi-condition, scored by specificity. |
|
|
241
265
|
| Reading `matchedAt === 'global'` | There is no `'global'`. The top of the chain is `'collection'`. |
|
|
@@ -302,4 +326,4 @@ interface EditorContext<TData> {
|
|
|
302
326
|
|
|
303
327
|
---
|
|
304
328
|
|
|
305
|
-
_End of doc. If anything below the SDK contradicts this file, this file wins — open a PR against the SDK to bring the two back into sync._
|
|
329
|
+
_End of doc. If anything below the SDK contradicts this file, this file wins — open a PR against the SDK to bring the two back into sync._
|
|
@@ -119,18 +119,37 @@ await SL.interactions.appendEvent(collectionId, {
|
|
|
119
119
|
|
|
120
120
|
### Public Event Submit
|
|
121
121
|
|
|
122
|
-
Use in client-side app code.
|
|
122
|
+
Use in client-side app code. Hits the public endpoint and respects interaction permissions (`allowPublicSubmit`, `allowAnonymousSubmit`, `requireAuth`, etc.).
|
|
123
123
|
|
|
124
124
|
```typescript
|
|
125
|
+
// Authenticated submission
|
|
125
126
|
await SL.interactions.submitPublicEvent(collectionId, {
|
|
126
127
|
appId: 'my-app',
|
|
127
128
|
interactionId: 'competition-entry',
|
|
128
129
|
outcome: 'entered',
|
|
129
|
-
|
|
130
|
+
contactId: currentUser.contactId,
|
|
130
131
|
metadata: { answer: 'Paris' },
|
|
131
132
|
});
|
|
133
|
+
|
|
134
|
+
// Anonymous submission (interaction must have allowAnonymousSubmit: true)
|
|
135
|
+
const response = await SL.interactions.submitPublicEvent(collectionId, {
|
|
136
|
+
appId: 'my-app',
|
|
137
|
+
interactionId: 'nps-score',
|
|
138
|
+
outcome: '9',
|
|
139
|
+
metadata: {
|
|
140
|
+
anonId: SL.utils.getAnonId(), // device-level dedup signal
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
if (!response.success) {
|
|
145
|
+
if (response.reason === 'duplicate_anon') {
|
|
146
|
+
// this device has already submitted
|
|
147
|
+
}
|
|
148
|
+
}
|
|
132
149
|
```
|
|
133
150
|
|
|
151
|
+
> **Anonymous submissions** — when `allowAnonymousSubmit: true` is set on the interaction, neither `userId` nor `contactId` is required. Use `utils.getAnonId()` to generate a stable browser-local UUID and pass it as `metadata.anonId`; the server will enforce `uniquePerAnonId` if configured.
|
|
152
|
+
|
|
134
153
|
### Update an Existing Event
|
|
135
154
|
|
|
136
155
|
```typescript
|
|
@@ -237,6 +256,8 @@ Set on the interaction type definition via `permissions`:
|
|
|
237
256
|
| `uniquePerUser` | boolean | Prevent duplicate submissions per user |
|
|
238
257
|
| `uniquePerUserWindowSeconds` | number | Time window for uniqueness (e.g., `86400` = 1 day) |
|
|
239
258
|
| `uniqueOutcome` | string | Outcome tag to check for duplicates (e.g., `"submitted"`) |
|
|
259
|
+
| `uniquePerAnonId` | boolean | Reject a second submission that carries the same `anonId` in metadata |
|
|
260
|
+
| `uniquePerAnonIdWindowSeconds` | number | Time window for `uniquePerAnonId` enforcement; `0` or omitted = all-time |
|
|
240
261
|
| `allowPublicSummary` | boolean | Show counts/aggregates to unauthenticated users |
|
|
241
262
|
| `allowAuthenticatedSummary` | boolean | Show counts/aggregates to authenticated users |
|
|
242
263
|
| `allowOwnRead` | boolean | Let users read their own event history via public API |
|
|
@@ -263,8 +284,10 @@ When defining a journey trigger, reference the `interactionId` that should fire
|
|
|
263
284
|
|
|
264
285
|
```typescript
|
|
265
286
|
import type {
|
|
266
|
-
AppendInteractionBody, // Event body for appendEvent
|
|
287
|
+
AppendInteractionBody, // Event body for appendEvent and submitPublicEvent
|
|
267
288
|
UpdateInteractionBody, // Event body for updateEvent
|
|
289
|
+
SubmitInteractionResponse, // { success: true; eventId: string }
|
|
290
|
+
SubmitInteractionError, // { error: 'FORBIDDEN'; reason: string }
|
|
268
291
|
InteractionEventRow, // Raw event record returned by query()
|
|
269
292
|
OutcomeCount, // { outcome: string | null; count: number }
|
|
270
293
|
InteractionPermissions, // Full permissions config shape
|
package/dist/docs/overview.md
CHANGED
|
@@ -70,7 +70,7 @@ The SmartLinks SDK (`@proveanything/smartlinks`) includes comprehensive document
|
|
|
70
70
|
| **AI Guide Template** | `docs/ai-guide-template.md` | Template for creating `public/ai-guide.md` — customise per app |
|
|
71
71
|
| **Forms** | `docs/forms.md` | Form definitions, schema-driven rendering, submission patterns |
|
|
72
72
|
| **Auth Kit** | `docs/auth-kit.md` | End-user sign-in: email/password, magic links, phone OTP, Google OAuth |
|
|
73
|
-
| **Records
|
|
73
|
+
| **App Records Pattern** | `docs/app-records-pattern.md` | Standard pattern for per-product/facet/variant/batch admin + public widget UIs |
|
|
74
74
|
| **UI Utils** | `docs/ui-utils.md` | `@proveanything/smartlinks-utils-ui` — React shells, hooks, and primitives for records-based apps |
|
|
75
75
|
|
|
76
76
|
---
|
package/dist/docs/ui-utils.md
CHANGED
|
@@ -19,7 +19,7 @@ translate SDK data into consistent admin interfaces.
|
|
|
19
19
|
|
|
20
20
|
**When do you need it?**
|
|
21
21
|
|
|
22
|
-
- You are building an admin UI for a records-based microapp (see [records-
|
|
22
|
+
- You are building an admin UI for a records-based microapp (see [app-records-pattern.md](app-records-pattern.md))
|
|
23
23
|
- You need a media asset picker, icon picker, or font picker in an admin panel
|
|
24
24
|
- You need a recursive rule/conditions editor for targeting or audience logic
|
|
25
25
|
- You want the standard inheritance/override editor for scoped records
|
|
@@ -326,7 +326,7 @@ Props:
|
|
|
326
326
|
|
|
327
327
|
Free-text facet entry is **not** supported — admins must pick from defined facets.
|
|
328
328
|
|
|
329
|
-
See [records-
|
|
329
|
+
See [app-records-pattern.md §4](app-records-pattern.md#4-admin-side----recordsadminshell-the-only-thing-you-should-be-writing) for the standalone usage example.
|
|
330
330
|
|
|
331
331
|
---
|
|
332
332
|
|
|
@@ -505,6 +505,6 @@ your microapp ← domain logic and custom forms
|
|
|
505
505
|
|
|
506
506
|
## Further reading
|
|
507
507
|
|
|
508
|
-
- [records-
|
|
508
|
+
- [app-records-pattern.md](app-records-pattern.md) — the data contract that `RecordsAdminShell` implements
|
|
509
509
|
- [building-react-components.md](building-react-components.md) — dual-mode rendering rules that apply to all components
|
|
510
510
|
- [app-manifest.md](app-manifest.md) — the `records` manifest block that drives tab generation
|
package/dist/openapi.yaml
CHANGED
|
@@ -10355,6 +10355,40 @@ paths:
|
|
|
10355
10355
|
application/json:
|
|
10356
10356
|
schema:
|
|
10357
10357
|
$ref: "#/components/schemas/PublicInteractionsCountsByOutcomeRequest"
|
|
10358
|
+
/public/collection/{collectionId}/interactions/submit:
|
|
10359
|
+
post:
|
|
10360
|
+
tags:
|
|
10361
|
+
- interactions
|
|
10362
|
+
summary: "POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context."
|
|
10363
|
+
operationId: interactions_submitPublicEvent
|
|
10364
|
+
security: []
|
|
10365
|
+
parameters:
|
|
10366
|
+
- name: collectionId
|
|
10367
|
+
in: path
|
|
10368
|
+
required: true
|
|
10369
|
+
schema:
|
|
10370
|
+
type: string
|
|
10371
|
+
responses:
|
|
10372
|
+
200:
|
|
10373
|
+
description: Success
|
|
10374
|
+
content:
|
|
10375
|
+
application/json:
|
|
10376
|
+
schema:
|
|
10377
|
+
oneOf:
|
|
10378
|
+
- $ref: "#/components/schemas/SubmitInteractionResponse"
|
|
10379
|
+
- $ref: "#/components/schemas/SubmitInteractionError"
|
|
10380
|
+
400:
|
|
10381
|
+
description: Bad request
|
|
10382
|
+
401:
|
|
10383
|
+
description: Unauthorized
|
|
10384
|
+
404:
|
|
10385
|
+
description: Not found
|
|
10386
|
+
requestBody:
|
|
10387
|
+
required: true
|
|
10388
|
+
content:
|
|
10389
|
+
application/json:
|
|
10390
|
+
schema:
|
|
10391
|
+
$ref: "#/components/schemas/AppendInteractionBody"
|
|
10358
10392
|
/public/collection/{collectionId}/interactions/{id}:
|
|
10359
10393
|
get:
|
|
10360
10394
|
tags:
|
|
@@ -20187,6 +20221,10 @@ components:
|
|
|
20187
20221
|
type: boolean
|
|
20188
20222
|
allowOwnRead:
|
|
20189
20223
|
type: boolean
|
|
20224
|
+
uniquePerAnonId:
|
|
20225
|
+
type: boolean
|
|
20226
|
+
uniquePerAnonIdWindowSeconds:
|
|
20227
|
+
type: number
|
|
20190
20228
|
InteractionDisplay:
|
|
20191
20229
|
type: object
|
|
20192
20230
|
properties:
|
|
@@ -20275,6 +20313,26 @@ components:
|
|
|
20275
20313
|
type: number
|
|
20276
20314
|
offset:
|
|
20277
20315
|
type: number
|
|
20316
|
+
SubmitInteractionResponse:
|
|
20317
|
+
type: object
|
|
20318
|
+
properties:
|
|
20319
|
+
success:
|
|
20320
|
+
type: object
|
|
20321
|
+
additionalProperties: true
|
|
20322
|
+
eventId:
|
|
20323
|
+
type: string
|
|
20324
|
+
required:
|
|
20325
|
+
- success
|
|
20326
|
+
- eventId
|
|
20327
|
+
SubmitInteractionError:
|
|
20328
|
+
type: object
|
|
20329
|
+
properties:
|
|
20330
|
+
error:
|
|
20331
|
+
type: string
|
|
20332
|
+
enum:
|
|
20333
|
+
- FORBIDDEN
|
|
20334
|
+
required:
|
|
20335
|
+
- error
|
|
20278
20336
|
Job:
|
|
20279
20337
|
type: object
|
|
20280
20338
|
properties:
|
|
@@ -162,6 +162,13 @@ export interface InteractionPermissions {
|
|
|
162
162
|
allowAuthenticatedSummary?: boolean;
|
|
163
163
|
/** Allow an authenticated user to read their own interaction history via the public API. */
|
|
164
164
|
allowOwnRead?: boolean;
|
|
165
|
+
/** Reject a second submission that carries the same `anonId` in metadata. */
|
|
166
|
+
uniquePerAnonId?: boolean;
|
|
167
|
+
/**
|
|
168
|
+
* Time window in seconds for `uniquePerAnonId` enforcement.
|
|
169
|
+
* `0` or omitted means all-time deduplication.
|
|
170
|
+
*/
|
|
171
|
+
uniquePerAnonIdWindowSeconds?: number;
|
|
165
172
|
}
|
|
166
173
|
export interface InteractionDisplay {
|
|
167
174
|
title?: string;
|
|
@@ -203,3 +210,11 @@ export interface ListInteractionTypesQuery {
|
|
|
203
210
|
limit?: number;
|
|
204
211
|
offset?: number;
|
|
205
212
|
}
|
|
213
|
+
export interface SubmitInteractionResponse {
|
|
214
|
+
success: true;
|
|
215
|
+
eventId: string;
|
|
216
|
+
}
|
|
217
|
+
export interface SubmitInteractionError {
|
|
218
|
+
error: 'FORBIDDEN';
|
|
219
|
+
reason: 'not_public' | 'auth_required' | 'duplicate' | 'duplicate_anon' | 'disabled' | 'before_start' | 'after_end' | 'origin_forbidden';
|
|
220
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns a stable anonymous device ID stored in `localStorage`.
|
|
3
|
+
*
|
|
4
|
+
* On first call a random UUID is generated and persisted. All subsequent calls
|
|
5
|
+
* from the same browser return the same value. Returns `null` during SSR or in
|
|
6
|
+
* any environment without `window` (safe to call universally).
|
|
7
|
+
*
|
|
8
|
+
* The value is scoped to the browser's `localStorage` and is cleared if the
|
|
9
|
+
* user clears site data. It does **not** create any contact or user record on
|
|
10
|
+
* the server.
|
|
11
|
+
*
|
|
12
|
+
* Pass the result in `metadata.anonId` when submitting interactions to enable
|
|
13
|
+
* device-level deduplication via `uniquePerAnonId`.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* import { utils } from '@proveanything/smartlinks'
|
|
18
|
+
*
|
|
19
|
+
* const response = await interactions.submitPublicEvent(collectionId, {
|
|
20
|
+
* appId: 'my-app',
|
|
21
|
+
* interactionId: 'nps-score',
|
|
22
|
+
* outcome: '9',
|
|
23
|
+
* metadata: { anonId: utils.getAnonId() },
|
|
24
|
+
* })
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export declare function getAnonId(): string | null;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// src/utils/anonId.ts
|
|
2
|
+
const ANON_ID_KEY = '_pa_anon_id';
|
|
3
|
+
/**
|
|
4
|
+
* Returns a stable anonymous device ID stored in `localStorage`.
|
|
5
|
+
*
|
|
6
|
+
* On first call a random UUID is generated and persisted. All subsequent calls
|
|
7
|
+
* from the same browser return the same value. Returns `null` during SSR or in
|
|
8
|
+
* any environment without `window` (safe to call universally).
|
|
9
|
+
*
|
|
10
|
+
* The value is scoped to the browser's `localStorage` and is cleared if the
|
|
11
|
+
* user clears site data. It does **not** create any contact or user record on
|
|
12
|
+
* the server.
|
|
13
|
+
*
|
|
14
|
+
* Pass the result in `metadata.anonId` when submitting interactions to enable
|
|
15
|
+
* device-level deduplication via `uniquePerAnonId`.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```ts
|
|
19
|
+
* import { utils } from '@proveanything/smartlinks'
|
|
20
|
+
*
|
|
21
|
+
* const response = await interactions.submitPublicEvent(collectionId, {
|
|
22
|
+
* appId: 'my-app',
|
|
23
|
+
* interactionId: 'nps-score',
|
|
24
|
+
* outcome: '9',
|
|
25
|
+
* metadata: { anonId: utils.getAnonId() },
|
|
26
|
+
* })
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export function getAnonId() {
|
|
30
|
+
if (typeof window === 'undefined')
|
|
31
|
+
return null;
|
|
32
|
+
let id = window.localStorage.getItem(ANON_ID_KEY);
|
|
33
|
+
if (!id) {
|
|
34
|
+
id = crypto.randomUUID();
|
|
35
|
+
window.localStorage.setItem(ANON_ID_KEY, id);
|
|
36
|
+
}
|
|
37
|
+
return id;
|
|
38
|
+
}
|
package/dist/utils/index.d.ts
CHANGED
package/dist/utils/index.js
CHANGED
package/docs/API_SUMMARY.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Smartlinks API Summary
|
|
2
2
|
|
|
3
|
-
Version: 1.11.
|
|
3
|
+
Version: 1.11.8 | Generated: 2026-05-03T08:12:27.170Z
|
|
4
4
|
|
|
5
5
|
This is a concise summary of all available API functions and types.
|
|
6
6
|
|
|
@@ -5153,6 +5153,10 @@ interface InteractionPermissions {
|
|
|
5153
5153
|
* Authenticated summary visibility (counts, aggregates) when user is signed in.
|
|
5154
5154
|
allowAuthenticatedSummary?: boolean
|
|
5155
5155
|
allowOwnRead?: boolean
|
|
5156
|
+
uniquePerAnonId?: boolean
|
|
5157
|
+
* Time window in seconds for `uniquePerAnonId` enforcement.
|
|
5158
|
+
* `0` or omitted means all-time deduplication.
|
|
5159
|
+
uniquePerAnonIdWindowSeconds?: number
|
|
5156
5160
|
}
|
|
5157
5161
|
```
|
|
5158
5162
|
|
|
@@ -5220,6 +5224,30 @@ interface ListInteractionTypesQuery {
|
|
|
5220
5224
|
}
|
|
5221
5225
|
```
|
|
5222
5226
|
|
|
5227
|
+
**SubmitInteractionResponse** (interface)
|
|
5228
|
+
```typescript
|
|
5229
|
+
interface SubmitInteractionResponse {
|
|
5230
|
+
success: true
|
|
5231
|
+
eventId: string
|
|
5232
|
+
}
|
|
5233
|
+
```
|
|
5234
|
+
|
|
5235
|
+
**SubmitInteractionError** (interface)
|
|
5236
|
+
```typescript
|
|
5237
|
+
interface SubmitInteractionError {
|
|
5238
|
+
error: 'FORBIDDEN'
|
|
5239
|
+
reason:
|
|
5240
|
+
| 'not_public'
|
|
5241
|
+
| 'auth_required'
|
|
5242
|
+
| 'duplicate'
|
|
5243
|
+
| 'duplicate_anon'
|
|
5244
|
+
| 'disabled'
|
|
5245
|
+
| 'before_start'
|
|
5246
|
+
| 'after_end'
|
|
5247
|
+
| 'origin_forbidden'
|
|
5248
|
+
}
|
|
5249
|
+
```
|
|
5250
|
+
|
|
5223
5251
|
### jobs
|
|
5224
5252
|
|
|
5225
5253
|
**Job** (interface)
|
|
@@ -8333,47 +8361,47 @@ POST /admin/collection/:collectionId/interactions/append Appends one interaction
|
|
|
8333
8361
|
POST /admin/collection/:collectionId/interactions/append Appends one interaction event.
|
|
8334
8362
|
|
|
8335
8363
|
**submitPublicEvent**(collectionId: string,
|
|
8336
|
-
body: AppendInteractionBody) → `Promise
|
|
8337
|
-
|
|
8364
|
+
body: AppendInteractionBody) → `Promise<SubmitInteractionResponse | SubmitInteractionError>`
|
|
8365
|
+
POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context. When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor `contactId` is required. Pass `anonId` inside `metadata` to enable device-level deduplication via `uniquePerAnonId`.
|
|
8338
8366
|
|
|
8339
8367
|
**create**(collectionId: string,
|
|
8340
8368
|
body: CreateInteractionTypeBody) → `Promise<InteractionTypeRecord>`
|
|
8341
|
-
|
|
8369
|
+
POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context. When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor `contactId` is required. Pass `anonId` inside `metadata` to enable device-level deduplication via `uniquePerAnonId`.
|
|
8342
8370
|
|
|
8343
8371
|
**list**(collectionId: string,
|
|
8344
8372
|
query: ListInteractionTypesQuery = {}) → `Promise<InteractionTypeList>`
|
|
8345
|
-
|
|
8373
|
+
POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context. When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor `contactId` is required. Pass `anonId` inside `metadata` to enable device-level deduplication via `uniquePerAnonId`.
|
|
8346
8374
|
|
|
8347
8375
|
**get**(collectionId: string,
|
|
8348
8376
|
id: string) → `Promise<InteractionTypeRecord>`
|
|
8349
|
-
|
|
8377
|
+
POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context. When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor `contactId` is required. Pass `anonId` inside `metadata` to enable device-level deduplication via `uniquePerAnonId`.
|
|
8350
8378
|
|
|
8351
8379
|
**update**(collectionId: string,
|
|
8352
8380
|
id: string,
|
|
8353
8381
|
patchBody: UpdateInteractionTypeBody) → `Promise<InteractionTypeRecord>`
|
|
8354
|
-
|
|
8382
|
+
POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context. When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor `contactId` is required. Pass `anonId` inside `metadata` to enable device-level deduplication via `uniquePerAnonId`.
|
|
8355
8383
|
|
|
8356
8384
|
**remove**(collectionId: string,
|
|
8357
8385
|
id: string) → `Promise<void>`
|
|
8358
|
-
|
|
8386
|
+
POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context. When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor `contactId` is required. Pass `anonId` inside `metadata` to enable device-level deduplication via `uniquePerAnonId`.
|
|
8359
8387
|
|
|
8360
8388
|
**publicCountsByOutcome**(collectionId: string,
|
|
8361
8389
|
body: PublicInteractionsCountsByOutcomeRequest,
|
|
8362
8390
|
authToken?: string) → `Promise<OutcomeCount[]>`
|
|
8363
|
-
|
|
8391
|
+
POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context. When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor `contactId` is required. Pass `anonId` inside `metadata` to enable device-level deduplication via `uniquePerAnonId`.
|
|
8364
8392
|
|
|
8365
8393
|
**publicMyInteractions**(collectionId: string,
|
|
8366
8394
|
body: PublicInteractionsByUserRequest,
|
|
8367
8395
|
authToken?: string) → `Promise<InteractionEventRow[]>`
|
|
8368
|
-
|
|
8396
|
+
POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context. When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor `contactId` is required. Pass `anonId` inside `metadata` to enable device-level deduplication via `uniquePerAnonId`.
|
|
8369
8397
|
|
|
8370
8398
|
**publicList**(collectionId: string,
|
|
8371
8399
|
query: ListInteractionTypesQuery = {}) → `Promise<InteractionTypeList>`
|
|
8372
|
-
|
|
8400
|
+
POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context. When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor `contactId` is required. Pass `anonId` inside `metadata` to enable device-level deduplication via `uniquePerAnonId`.
|
|
8373
8401
|
|
|
8374
8402
|
**publicGet**(collectionId: string,
|
|
8375
8403
|
id: string) → `Promise<InteractionTypeRecord>`
|
|
8376
|
-
|
|
8404
|
+
POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context. When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor `contactId` is required. Pass `anonId` inside `metadata` to enable device-level deduplication via `uniquePerAnonId`.
|
|
8377
8405
|
|
|
8378
8406
|
### jobs
|
|
8379
8407
|
|
package/docs/app-manifest.md
CHANGED
|
@@ -270,7 +270,7 @@ See the [Deep Link Discovery guide](deep-link-discovery.md) for the full dual-so
|
|
|
270
270
|
|
|
271
271
|
#### `records`
|
|
272
272
|
|
|
273
|
-
Declares which `app.records` record types the app stores, and which scopes each type supports. Required for any app that follows the [Records
|
|
273
|
+
Declares which `app.records` record types the app stores, and which scopes each type supports. Required for any app that follows the [App Records Pattern](app-records-pattern.md). Omit if the app does not use scoped records.
|
|
274
274
|
|
|
275
275
|
The platform and the `<RecordsAdminShell>` from `@proveanything/smartlinks-utils-ui` read this block to render the right scope tabs, rule editor, and cardinality-appropriate right pane.
|
|
276
276
|
|
|
@@ -294,7 +294,7 @@ The platform and the `<RecordsAdminShell>` from `@proveanything/smartlinks-utils
|
|
|
294
294
|
| `scopes` | string[] | — | Allowed scope kinds in resolution order. Valid values: `"collection"`, `"product"`, `"variant"`, `"batch"`, `"facet"`, `"proof"`, `"rule"`. `'rule'` is a synthetic scope holding `facetRule`-targeted records. `'collection'` replaces the legacy empty-ref catch-all — **there is no `'global'` scope**. |
|
|
295
295
|
| `defaultScope` | string | — | The scope the "Create new" button targets in the admin shell. Must be one of the declared `scopes`. |
|
|
296
296
|
|
|
297
|
-
An app may declare multiple record types under different keys (e.g. `"nutrition"` and `"cooking_steps"`). See [records-
|
|
297
|
+
An app may declare multiple record types under different keys (e.g. `"nutrition"` and `"cooking_steps"`). See [app-records-pattern.md](app-records-pattern.md) for the full admin + public pattern.
|
|
298
298
|
|
|
299
299
|
#### `executor`
|
|
300
300
|
|
package/docs/app-objects.md
CHANGED
|
@@ -445,7 +445,7 @@ const productComments = await app.threads.list(collectionId, appId, {
|
|
|
445
445
|
- **Usage logs** — record product usage metrics
|
|
446
446
|
- **Audit trails** — immutable logs of actions
|
|
447
447
|
- **Loyalty points** — track points earned/redeemed
|
|
448
|
-
- **Per-product / per-facet configuration** — scoped data that varies by product axis (see [records-
|
|
448
|
+
- **Per-product / per-facet configuration** — scoped data that varies by product axis (see [app-records-pattern.md](app-records-pattern.md))
|
|
449
449
|
|
|
450
450
|
### Key Features
|
|
451
451
|
|
|
@@ -526,7 +526,7 @@ proof → batch → variant → product → rule(*) → facet(*) →
|
|
|
526
526
|
- `facet(*)` — legacy single-facet anchors, walked alphabetically. Prefer `facetRule` for new work.
|
|
527
527
|
- `collection` — the top of the chain and the catch-all for any record with no anchor fields. **There is no `'global'` tier above collection.**
|
|
528
528
|
|
|
529
|
-
For a **singleton** record type (one answer wins), use `useResolvedRecord` — it performs this walk server-side and returns the first match plus a `matchedAt` tag. For a **collection** record type (every match is shown), use `useCollectedRecords`. See [records-
|
|
529
|
+
For a **singleton** record type (one answer wins), use `useResolvedRecord` — it performs this walk server-side and returns the first match plus a `matchedAt` tag. For a **collection** record type (every match is shown), use `useCollectedRecords`. See [app-records-pattern.md §2](app-records-pattern.md#2-resolution-order-one-canonical-chain) for the full guide.
|
|
530
530
|
|
|
531
531
|
### Singleton Cardinality
|
|
532
532
|
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
# SmartLinks App Records Pattern
|
|
2
|
+
|
|
3
|
+
> Canonical guide for microapps that store **per-product**, **per-facet**, **per-variant**, **per-batch**, or **rule-targeted** data.
|
|
4
|
+
>
|
|
5
|
+
> Audience: microapp developers (ingredients, nutrition, allergy, FAQs, recipes, warranty, provenance, …).
|
|
6
|
+
>
|
|
7
|
+
> Status: **standard**. New apps MUST follow this contract; existing apps SHOULD migrate.
|
|
8
|
+
>
|
|
9
|
+
> SDK: `@proveanything/smartlinks` ≥ **1.11**, `@proveanything/smartlinks-utils-ui` ≥ **0.7.6**.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## 0. TL;DR — pick your shape, then copy the snippet
|
|
14
|
+
|
|
15
|
+
Every records-based app fits into a 2×2:
|
|
16
|
+
|
|
17
|
+
| | **Singleton** (one record per scope) | **Collection** (many records per scope) |
|
|
18
|
+
| ------------------------ | ---------------------------------------------------- | ------------------------------------------------------------- |
|
|
19
|
+
| **Best-match (one wins)**| Ingredients, nutrition, washing instructions | _(rare — usually you want all)_ |
|
|
20
|
+
| **All matches (aggregate)** | _(rare — usually you want best)_ | FAQs, recipes, SOPs, care tips, story cards |
|
|
21
|
+
|
|
22
|
+
That choice drives **three** things and nothing else:
|
|
23
|
+
|
|
24
|
+
1. **Manifest:** `cardinality: 'singleton' | 'collection'` and `allowFacetRules: boolean`.
|
|
25
|
+
2. **Admin (`<RecordsAdminShell>`):** pass `cardinality` + include `'rule'` in `scopes` if `allowFacetRules`.
|
|
26
|
+
3. **Public hook:** `useResolvedRecord` (best match) **or** `useCollectedRecords` / `useResolveAllRecords` (all matches).
|
|
27
|
+
|
|
28
|
+
If you only remember one rule: **never write your own resolver**. The shell, the hooks, and the SDK already agree on resolution order. Reimplementing it is how apps drift.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## 1. The data model in one paragraph
|
|
33
|
+
|
|
34
|
+
A microapp owns a typed **records table** keyed by `(appId, recordType, id)`. Each `AppRecord` carries a `data` payload plus **either** a structured `scope` (anchored to a node in the chain) **or** a `facetRule` (matches products dynamically by their facets). The server resolves which record(s) apply to a given product context. There is no "global"; the top of the chain is **collection** — anything not explicitly scoped further applies to the whole collection.
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import * as SL from '@proveanything/smartlinks';
|
|
38
|
+
|
|
39
|
+
await SL.app.records.upsert(collectionId, appId, {
|
|
40
|
+
recordType: 'ingredients',
|
|
41
|
+
scope: { productId: 'prod_abc', variantId: 'var_500ml' }, // server derives the ref
|
|
42
|
+
data: { /* domain payload */ },
|
|
43
|
+
}, /* admin */ true);
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Or, for a rule-targeted record:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
await SL.app.records.upsert(collectionId, appId, {
|
|
50
|
+
recordType: 'ingredients',
|
|
51
|
+
facetRule: {
|
|
52
|
+
all: [
|
|
53
|
+
{ facetKey: 'brand', anyOf: ['acme'] },
|
|
54
|
+
{ facetKey: 'category', anyOf: ['bread', 'pastry'] },
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
data: { /* domain payload */ },
|
|
58
|
+
}, true);
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`scope` and `facetRule` are **mutually exclusive on save**.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## 2. Resolution order (one canonical chain)
|
|
66
|
+
|
|
67
|
+
The server walks **most-specific → least-specific** and stops at the first match (for best-match) or collects every match (for aggregate):
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
proof → batch → variant → product → rule(*) → facet(*) → collection
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
- `rule(*)` — facet-rule records are scored by **specificity** (number of clauses + number of constrained values). The most specific rule wins.
|
|
74
|
+
- `facet(*)` — legacy single-facet anchors, walked deterministically (alphabetical).
|
|
75
|
+
- `collection` — the top of the chain. **There is no "global" tier above collection.** A collection-level record is the catch-all for that collection.
|
|
76
|
+
|
|
77
|
+
The resolved value comes back tagged with `matchedAt: 'product' | 'rule' | 'facet' | …` so the UI can say things like _"Matched by rule: brand=Acme AND category=bread"_.
|
|
78
|
+
|
|
79
|
+
> ⚠️ Legacy `scope.facets[]` (colon-delimited single-facet refs) is deprecated and removed in SDK 1.12. Use `facetRule` for everything that isn't a one-off facet pin.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## 3. Manifest declaration
|
|
84
|
+
|
|
85
|
+
Declare each record type once in `app.admin.json`. The shell and the platform read this to render the right scope tabs and disable the wrong affordances.
|
|
86
|
+
|
|
87
|
+
```json
|
|
88
|
+
{
|
|
89
|
+
"records": {
|
|
90
|
+
"ingredients": {
|
|
91
|
+
"label": "Ingredients",
|
|
92
|
+
"cardinality": "singleton",
|
|
93
|
+
"allowFacetRules": true,
|
|
94
|
+
"scopes": ["collection", "facet", "rule", "product", "variant", "batch"],
|
|
95
|
+
"defaultScope": "product"
|
|
96
|
+
},
|
|
97
|
+
"faq": {
|
|
98
|
+
"label": "FAQs",
|
|
99
|
+
"cardinality": "collection",
|
|
100
|
+
"allowFacetRules": true,
|
|
101
|
+
"scopes": ["collection", "rule", "product"],
|
|
102
|
+
"defaultScope": "collection"
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
| Field | Meaning |
|
|
109
|
+
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
|
110
|
+
| `cardinality` | `'singleton'` (one per scope, e.g. ingredients) or `'collection'` (many per scope, e.g. FAQs). Default `'singleton'`. |
|
|
111
|
+
| `allowFacetRules` | `true` to enable the `rule` scope tab + `<FacetRuleEditor>` in the shell. Default `false`. |
|
|
112
|
+
| `scopes` | Allowed scope kinds in **resolution order**. `'rule'` is a synthetic scope that holds rule-targeted records. |
|
|
113
|
+
| `defaultScope` | Where the "Create new" button lands. |
|
|
114
|
+
| `label` | Human-readable label used in headings and toasts. |
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## 4. Admin side — `<RecordsAdminShell>` (the only thing you should be writing)
|
|
119
|
+
|
|
120
|
+
The shell owns: scope tabs, browser pane, rule editor, save/discard, dirty navigation, inheritance markers, deletion, CSV, bulk apply, deep linking. **You only supply the editor for one record's `data`.**
|
|
121
|
+
|
|
122
|
+
```tsx
|
|
123
|
+
import * as SL from '@proveanything/smartlinks';
|
|
124
|
+
import { RecordsAdminShell } from '@proveanything/smartlinks-utils-ui/records-admin';
|
|
125
|
+
|
|
126
|
+
<RecordsAdminShell<IngredientsConfig>
|
|
127
|
+
SL={SL}
|
|
128
|
+
collectionId={collectionId}
|
|
129
|
+
appId={appId}
|
|
130
|
+
recordType="ingredients"
|
|
131
|
+
label="Ingredients"
|
|
132
|
+
cardinality="singleton" // ← from manifest
|
|
133
|
+
scopes={['collection', 'facet', 'rule', 'product', 'variant', 'batch']}
|
|
134
|
+
defaultScope="product"
|
|
135
|
+
defaultData={() => emptyConfig()}
|
|
136
|
+
renderEditor={(ctx) => (
|
|
137
|
+
<IngredientsEditor
|
|
138
|
+
value={ctx.value}
|
|
139
|
+
onChange={ctx.onChange}
|
|
140
|
+
// For rule-targeted records, the shell hands you the live rule + setter:
|
|
141
|
+
facetRule={ctx.facetRule}
|
|
142
|
+
onFacetRuleChange={ctx.onFacetRuleChange}
|
|
143
|
+
/>
|
|
144
|
+
)}
|
|
145
|
+
/>
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### What the shell gives you for free
|
|
149
|
+
|
|
150
|
+
- **Scope tabs** including a **`Rule`** tab when `'rule'` is in `scopes`. Selecting it opens `<FacetRuleEditor>` above your editor — no extra wiring.
|
|
151
|
+
- **`EditorContext.facetRule` / `onFacetRuleChange`** for rule-scoped records, plus `canSave: false` until at least one clause has values (avoids server 500s).
|
|
152
|
+
- **Inheritance markers** — when editing a variant, the product baseline is shown; per-field "↩ Inherited" / "● Override" is rendered by the inheritance helpers.
|
|
153
|
+
- **Collection cardinality flow** — set `cardinality="collection"` and the shell turns the right pane into a list of items (table / cards / gallery) with `+ New` and per-item nav.
|
|
154
|
+
- **Telemetry** — `record.save`, `record.delete`, `scope.change`, `csv.import`, `bulk.apply`, `item.create`, etc. via `onTelemetry`.
|
|
155
|
+
|
|
156
|
+
### Standalone rule editor
|
|
157
|
+
|
|
158
|
+
If you need a rule editor outside the shell (e.g. on a settings page):
|
|
159
|
+
|
|
160
|
+
```tsx
|
|
161
|
+
import { FacetRuleEditor } from '@proveanything/smartlinks-utils-ui/facet-rule-editor';
|
|
162
|
+
|
|
163
|
+
<FacetRuleEditor
|
|
164
|
+
value={rule}
|
|
165
|
+
onChange={setRule}
|
|
166
|
+
collectionId={collectionId} // lazy-fetches facets via SL.facets.publicList
|
|
167
|
+
preview={rulePreview} // optional — wire from useRulePreview
|
|
168
|
+
/>
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## 5. Public side — pick the right hook (this is where apps go wrong)
|
|
174
|
+
|
|
175
|
+
> **Admin vs public — the rule is simple:**
|
|
176
|
+
>
|
|
177
|
+
> | Function | Public widget | Admin dashboard |
|
|
178
|
+
> |---|---|---|
|
|
179
|
+
> | `app.records.create(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
|
|
180
|
+
> | `app.records.list(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
|
|
181
|
+
> | `app.records.get(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
|
|
182
|
+
> | `app.records.update(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
|
|
183
|
+
> | `app.records.remove(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
|
|
184
|
+
> | `app.records.aggregate(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
|
|
185
|
+
> | `app.records.match(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
|
|
186
|
+
> | `app.records.resolveAll(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
|
|
187
|
+
> | `app.records.upsert()` | ❌ admin only — no public path | ✅ |
|
|
188
|
+
> | `app.records.bulkUpsert()` | ❌ admin only — no public path | ✅ |
|
|
189
|
+
> | `app.records.bulkDelete()` | ❌ admin only — no public path | ✅ |
|
|
190
|
+
> | `app.records.restore()` | ❌ admin only — no public path | ✅ |
|
|
191
|
+
> | `app.records.previewRule()` | ❌ admin only — no public path | ✅ |
|
|
192
|
+
>
|
|
193
|
+
> The `admin` parameter on `match()` and `resolveAll()` defaults to `false`, so public widgets that omit it are fine. **Never hardcode `true` in a public widget.** The hooks below always use the public path and are the recommended approach — prefer them over raw SDK calls in widget code.
|
|
194
|
+
|
|
195
|
+
There is exactly **one decision** to make on the public side, and it follows from the manifest's `cardinality`:
|
|
196
|
+
|
|
197
|
+
### 5a. Singleton → `useResolvedRecord` (best match wins)
|
|
198
|
+
|
|
199
|
+
Use this when the app shows **one** answer for the current product (ingredients, nutrition, washing instructions, warranty terms).
|
|
200
|
+
|
|
201
|
+
```tsx
|
|
202
|
+
import * as SL from '@proveanything/smartlinks';
|
|
203
|
+
import { useResolvedRecord } from '@proveanything/smartlinks-utils-ui/records-admin';
|
|
204
|
+
|
|
205
|
+
const { data, source, sourceRef, matchedAt, matchedRule, isLoading } =
|
|
206
|
+
useResolvedRecord<IngredientsConfig>({
|
|
207
|
+
SL,
|
|
208
|
+
appId,
|
|
209
|
+
collectionId,
|
|
210
|
+
recordType: 'ingredients',
|
|
211
|
+
productId,
|
|
212
|
+
variantId, // optional
|
|
213
|
+
batchId, // optional
|
|
214
|
+
proofId, // optional
|
|
215
|
+
});
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
The resolver walks `proof → batch → variant → product → rule → facet → collection` and returns the **first match**, plus `matchedAt` so you can render _"From the product record"_ vs _"Matched by rule"_ badges if useful.
|
|
219
|
+
|
|
220
|
+
> Wrap this once in your app (e.g. `useResolvedIngredientSet`) so the rest of the codebase reads `{ data, isLoading }` and never sees the resolver.
|
|
221
|
+
|
|
222
|
+
### 5b. Collection → `useCollectedRecords` (every match, ordered)
|
|
223
|
+
|
|
224
|
+
Use this when the app shows **many** answers aggregated across the chain (FAQs, recipes, SOPs, care tips). Most-specific first by default; pass `sort` to override.
|
|
225
|
+
|
|
226
|
+
```tsx
|
|
227
|
+
import { useCollectedRecords } from '@proveanything/smartlinks-utils-ui/records-admin';
|
|
228
|
+
|
|
229
|
+
const { items, isLoading } = useCollectedRecords<FaqEntry>({
|
|
230
|
+
SL,
|
|
231
|
+
appId,
|
|
232
|
+
collectionId,
|
|
233
|
+
recordType: 'faq',
|
|
234
|
+
productId,
|
|
235
|
+
// sort: { kind: 'field', field: 'order', direction: 'asc' },
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// items: CollectedRecord<FaqEntry>[] — each has { data, scope, ref, depth }
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### 5c. Multi-type aggregate → `useResolveAllRecords`
|
|
242
|
+
|
|
243
|
+
When you need every record of every type that applies to a context (rare; mostly executors and SEO surfaces).
|
|
244
|
+
|
|
245
|
+
```tsx
|
|
246
|
+
import { useResolveAllRecords } from '@proveanything/smartlinks-utils-ui/records-admin';
|
|
247
|
+
|
|
248
|
+
const { entries, isLoading } = useResolveAllRecords({
|
|
249
|
+
SL, collectionId, appId,
|
|
250
|
+
context: { productId, facets: { brand: 'acme', category: ['bread'] } },
|
|
251
|
+
});
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
### Common mistakes (do not do these)
|
|
255
|
+
|
|
256
|
+
| ❌ Anti-pattern | ✅ Do this instead |
|
|
257
|
+
| ---------------------------------------------------------- | ----------------------------------------------------------------- |
|
|
258
|
+
| Calling `SL.app.records.list()` and filtering client-side | `useResolvedRecord` (singleton) or `useCollectedRecords` (collection). The server already knows the chain. |
|
|
259
|
+
| Calling `SL.app.records.list(…, true)` from a public widget | Omit the `admin` flag (defaults to `false`). `list()` has a public path — just don't pass `true`. |
|
|
260
|
+
| Calling `SL.app.records.match(…, true)` from a public widget | Omit the `admin` flag (defaults to `false`) or use `useResolvedRecord` / `useCollectedRecords`. |
|
|
261
|
+
| Calling `SL.app.records.resolveAll(…, true)` from a public widget | Omit the `admin` flag (defaults to `false`) or use `useResolveAllRecords`. |
|
|
262
|
+
| Calling `upsert`, `bulkUpsert`, `bulkDelete`, or `previewRule` from widget code | Those are admin-only. Widget code reads data — it never writes records directly. |
|
|
263
|
+
| Walking the chain by hand with multiple `getConfig` calls | One hook call. The resolver is tested, cached, and includes rules. |
|
|
264
|
+
| Treating `facet:key:value` refs as the rule mechanism | Use `facetRule` (`{ all: [{ facetKey, anyOf: [...] }] }`). Multi-condition, scored by specificity. |
|
|
265
|
+
| Reading `matchedAt === 'global'` | There is no `'global'`. The top of the chain is `'collection'`. |
|
|
266
|
+
| Building your own `<FacetRuleEditor>` | Use the one from `@proveanything/smartlinks-utils-ui/facet-rule-editor`. |
|
|
267
|
+
|
|
268
|
+
---
|
|
269
|
+
|
|
270
|
+
## 6. Reference: the `EditorContext` your `renderEditor` receives
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
interface EditorContext<TData> {
|
|
274
|
+
value: TData;
|
|
275
|
+
onChange: (next: TData) => void;
|
|
276
|
+
source: 'self' | 'inherited' | 'empty';
|
|
277
|
+
recordId?: string;
|
|
278
|
+
parentValue?: TData | null;
|
|
279
|
+
scope: ParsedRef; // { kind: 'product' | 'rule' | …, productId?, … }
|
|
280
|
+
|
|
281
|
+
// Save lifecycle
|
|
282
|
+
isDirty: boolean;
|
|
283
|
+
isSaving?: boolean;
|
|
284
|
+
saveError?: unknown | null;
|
|
285
|
+
canSave?: boolean; // shell flips to false on empty rules
|
|
286
|
+
cannotSaveReason?: string;
|
|
287
|
+
save: () => Promise<void>;
|
|
288
|
+
reset: () => void;
|
|
289
|
+
|
|
290
|
+
// Deletion
|
|
291
|
+
remove: () => Promise<void>;
|
|
292
|
+
canRemove: boolean;
|
|
293
|
+
|
|
294
|
+
// Rule scope only
|
|
295
|
+
facetRule?: FacetRule | null;
|
|
296
|
+
onFacetRuleChange?: (next: FacetRule | null) => void;
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
---
|
|
301
|
+
|
|
302
|
+
## 7. Migration checklist (existing apps)
|
|
303
|
+
|
|
304
|
+
1. **Update SDKs:** `@proveanything/smartlinks@^1.11`, `@proveanything/smartlinks-utils-ui@^0.7.6`.
|
|
305
|
+
2. **Add `cardinality` and `allowFacetRules`** to every entry under `records` in `app.admin.json`.
|
|
306
|
+
3. **Add `'rule'` (and `'collection'` if missing) to `scopes`** wherever `allowFacetRules: true`.
|
|
307
|
+
4. **Pass `cardinality`** to `<RecordsAdminShell>`.
|
|
308
|
+
5. **Replace any handwritten chain walking** with `useResolvedRecord` (singleton) or `useCollectedRecords` (collection).
|
|
309
|
+
6. **Delete any code that constructs `facet:key:value` refs** for matching. Use `facetRule` via the shell or `<FacetRuleEditor>`.
|
|
310
|
+
7. **Search for the word "global"** in your code/docs and rename to "collection" — this is the most common source of confusion.
|
|
311
|
+
|
|
312
|
+
---
|
|
313
|
+
|
|
314
|
+
## 8. Where the canonical exports live
|
|
315
|
+
|
|
316
|
+
| Need | Import from |
|
|
317
|
+
| ----------------------------- | ---------------------------------------------------------------------- |
|
|
318
|
+
| Admin shell | `@proveanything/smartlinks-utils-ui/records-admin` → `RecordsAdminShell` |
|
|
319
|
+
| Standalone rule editor | `@proveanything/smartlinks-utils-ui/facet-rule-editor` → `FacetRuleEditor` |
|
|
320
|
+
| Conditions editor (non-facet) | `@proveanything/smartlinks-utils-ui/conditions-editor` → `ConditionsEditor` |
|
|
321
|
+
| Best-match resolver hook | `@proveanything/smartlinks-utils-ui/records-admin` → `useResolvedRecord` |
|
|
322
|
+
| All-matches hook | `@proveanything/smartlinks-utils-ui/records-admin` → `useCollectedRecords` |
|
|
323
|
+
| Multi-type aggregate hook | `@proveanything/smartlinks-utils-ui/records-admin` → `useResolveAllRecords` |
|
|
324
|
+
| Rule preview ("matches N") | `@proveanything/smartlinks-utils-ui/records-admin` → `useRulePreview` |
|
|
325
|
+
| Server-side record CRUD | `@proveanything/smartlinks` → `SL.app.records.{upsert, list, remove, match, resolveAll}` |
|
|
326
|
+
|
|
327
|
+
---
|
|
328
|
+
|
|
329
|
+
_End of doc. If anything below the SDK contradicts this file, this file wins — open a PR against the SDK to bring the two back into sync._
|
package/docs/interactions.md
CHANGED
|
@@ -119,18 +119,37 @@ await SL.interactions.appendEvent(collectionId, {
|
|
|
119
119
|
|
|
120
120
|
### Public Event Submit
|
|
121
121
|
|
|
122
|
-
Use in client-side app code.
|
|
122
|
+
Use in client-side app code. Hits the public endpoint and respects interaction permissions (`allowPublicSubmit`, `allowAnonymousSubmit`, `requireAuth`, etc.).
|
|
123
123
|
|
|
124
124
|
```typescript
|
|
125
|
+
// Authenticated submission
|
|
125
126
|
await SL.interactions.submitPublicEvent(collectionId, {
|
|
126
127
|
appId: 'my-app',
|
|
127
128
|
interactionId: 'competition-entry',
|
|
128
129
|
outcome: 'entered',
|
|
129
|
-
|
|
130
|
+
contactId: currentUser.contactId,
|
|
130
131
|
metadata: { answer: 'Paris' },
|
|
131
132
|
});
|
|
133
|
+
|
|
134
|
+
// Anonymous submission (interaction must have allowAnonymousSubmit: true)
|
|
135
|
+
const response = await SL.interactions.submitPublicEvent(collectionId, {
|
|
136
|
+
appId: 'my-app',
|
|
137
|
+
interactionId: 'nps-score',
|
|
138
|
+
outcome: '9',
|
|
139
|
+
metadata: {
|
|
140
|
+
anonId: SL.utils.getAnonId(), // device-level dedup signal
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
if (!response.success) {
|
|
145
|
+
if (response.reason === 'duplicate_anon') {
|
|
146
|
+
// this device has already submitted
|
|
147
|
+
}
|
|
148
|
+
}
|
|
132
149
|
```
|
|
133
150
|
|
|
151
|
+
> **Anonymous submissions** — when `allowAnonymousSubmit: true` is set on the interaction, neither `userId` nor `contactId` is required. Use `utils.getAnonId()` to generate a stable browser-local UUID and pass it as `metadata.anonId`; the server will enforce `uniquePerAnonId` if configured.
|
|
152
|
+
|
|
134
153
|
### Update an Existing Event
|
|
135
154
|
|
|
136
155
|
```typescript
|
|
@@ -237,6 +256,8 @@ Set on the interaction type definition via `permissions`:
|
|
|
237
256
|
| `uniquePerUser` | boolean | Prevent duplicate submissions per user |
|
|
238
257
|
| `uniquePerUserWindowSeconds` | number | Time window for uniqueness (e.g., `86400` = 1 day) |
|
|
239
258
|
| `uniqueOutcome` | string | Outcome tag to check for duplicates (e.g., `"submitted"`) |
|
|
259
|
+
| `uniquePerAnonId` | boolean | Reject a second submission that carries the same `anonId` in metadata |
|
|
260
|
+
| `uniquePerAnonIdWindowSeconds` | number | Time window for `uniquePerAnonId` enforcement; `0` or omitted = all-time |
|
|
240
261
|
| `allowPublicSummary` | boolean | Show counts/aggregates to unauthenticated users |
|
|
241
262
|
| `allowAuthenticatedSummary` | boolean | Show counts/aggregates to authenticated users |
|
|
242
263
|
| `allowOwnRead` | boolean | Let users read their own event history via public API |
|
|
@@ -263,8 +284,10 @@ When defining a journey trigger, reference the `interactionId` that should fire
|
|
|
263
284
|
|
|
264
285
|
```typescript
|
|
265
286
|
import type {
|
|
266
|
-
AppendInteractionBody, // Event body for appendEvent
|
|
287
|
+
AppendInteractionBody, // Event body for appendEvent and submitPublicEvent
|
|
267
288
|
UpdateInteractionBody, // Event body for updateEvent
|
|
289
|
+
SubmitInteractionResponse, // { success: true; eventId: string }
|
|
290
|
+
SubmitInteractionError, // { error: 'FORBIDDEN'; reason: string }
|
|
268
291
|
InteractionEventRow, // Raw event record returned by query()
|
|
269
292
|
OutcomeCount, // { outcome: string | null; count: number }
|
|
270
293
|
InteractionPermissions, // Full permissions config shape
|
package/docs/overview.md
CHANGED
|
@@ -70,7 +70,7 @@ The SmartLinks SDK (`@proveanything/smartlinks`) includes comprehensive document
|
|
|
70
70
|
| **AI Guide Template** | `docs/ai-guide-template.md` | Template for creating `public/ai-guide.md` — customise per app |
|
|
71
71
|
| **Forms** | `docs/forms.md` | Form definitions, schema-driven rendering, submission patterns |
|
|
72
72
|
| **Auth Kit** | `docs/auth-kit.md` | End-user sign-in: email/password, magic links, phone OTP, Google OAuth |
|
|
73
|
-
| **Records
|
|
73
|
+
| **App Records Pattern** | `docs/app-records-pattern.md` | Standard pattern for per-product/facet/variant/batch admin + public widget UIs |
|
|
74
74
|
| **UI Utils** | `docs/ui-utils.md` | `@proveanything/smartlinks-utils-ui` — React shells, hooks, and primitives for records-based apps |
|
|
75
75
|
|
|
76
76
|
---
|
package/docs/ui-utils.md
CHANGED
|
@@ -19,7 +19,7 @@ translate SDK data into consistent admin interfaces.
|
|
|
19
19
|
|
|
20
20
|
**When do you need it?**
|
|
21
21
|
|
|
22
|
-
- You are building an admin UI for a records-based microapp (see [records-
|
|
22
|
+
- You are building an admin UI for a records-based microapp (see [app-records-pattern.md](app-records-pattern.md))
|
|
23
23
|
- You need a media asset picker, icon picker, or font picker in an admin panel
|
|
24
24
|
- You need a recursive rule/conditions editor for targeting or audience logic
|
|
25
25
|
- You want the standard inheritance/override editor for scoped records
|
|
@@ -326,7 +326,7 @@ Props:
|
|
|
326
326
|
|
|
327
327
|
Free-text facet entry is **not** supported — admins must pick from defined facets.
|
|
328
328
|
|
|
329
|
-
See [records-
|
|
329
|
+
See [app-records-pattern.md §4](app-records-pattern.md#4-admin-side----recordsadminshell-the-only-thing-you-should-be-writing) for the standalone usage example.
|
|
330
330
|
|
|
331
331
|
---
|
|
332
332
|
|
|
@@ -505,6 +505,6 @@ your microapp ← domain logic and custom forms
|
|
|
505
505
|
|
|
506
506
|
## Further reading
|
|
507
507
|
|
|
508
|
-
- [records-
|
|
508
|
+
- [app-records-pattern.md](app-records-pattern.md) — the data contract that `RecordsAdminShell` implements
|
|
509
509
|
- [building-react-components.md](building-react-components.md) — dual-mode rendering rules that apply to all components
|
|
510
510
|
- [app-manifest.md](app-manifest.md) — the `records` manifest block that drives tab generation
|
package/openapi.yaml
CHANGED
|
@@ -10355,6 +10355,40 @@ paths:
|
|
|
10355
10355
|
application/json:
|
|
10356
10356
|
schema:
|
|
10357
10357
|
$ref: "#/components/schemas/PublicInteractionsCountsByOutcomeRequest"
|
|
10358
|
+
/public/collection/{collectionId}/interactions/submit:
|
|
10359
|
+
post:
|
|
10360
|
+
tags:
|
|
10361
|
+
- interactions
|
|
10362
|
+
summary: "POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context."
|
|
10363
|
+
operationId: interactions_submitPublicEvent
|
|
10364
|
+
security: []
|
|
10365
|
+
parameters:
|
|
10366
|
+
- name: collectionId
|
|
10367
|
+
in: path
|
|
10368
|
+
required: true
|
|
10369
|
+
schema:
|
|
10370
|
+
type: string
|
|
10371
|
+
responses:
|
|
10372
|
+
200:
|
|
10373
|
+
description: Success
|
|
10374
|
+
content:
|
|
10375
|
+
application/json:
|
|
10376
|
+
schema:
|
|
10377
|
+
oneOf:
|
|
10378
|
+
- $ref: "#/components/schemas/SubmitInteractionResponse"
|
|
10379
|
+
- $ref: "#/components/schemas/SubmitInteractionError"
|
|
10380
|
+
400:
|
|
10381
|
+
description: Bad request
|
|
10382
|
+
401:
|
|
10383
|
+
description: Unauthorized
|
|
10384
|
+
404:
|
|
10385
|
+
description: Not found
|
|
10386
|
+
requestBody:
|
|
10387
|
+
required: true
|
|
10388
|
+
content:
|
|
10389
|
+
application/json:
|
|
10390
|
+
schema:
|
|
10391
|
+
$ref: "#/components/schemas/AppendInteractionBody"
|
|
10358
10392
|
/public/collection/{collectionId}/interactions/{id}:
|
|
10359
10393
|
get:
|
|
10360
10394
|
tags:
|
|
@@ -20187,6 +20221,10 @@ components:
|
|
|
20187
20221
|
type: boolean
|
|
20188
20222
|
allowOwnRead:
|
|
20189
20223
|
type: boolean
|
|
20224
|
+
uniquePerAnonId:
|
|
20225
|
+
type: boolean
|
|
20226
|
+
uniquePerAnonIdWindowSeconds:
|
|
20227
|
+
type: number
|
|
20190
20228
|
InteractionDisplay:
|
|
20191
20229
|
type: object
|
|
20192
20230
|
properties:
|
|
@@ -20275,6 +20313,26 @@ components:
|
|
|
20275
20313
|
type: number
|
|
20276
20314
|
offset:
|
|
20277
20315
|
type: number
|
|
20316
|
+
SubmitInteractionResponse:
|
|
20317
|
+
type: object
|
|
20318
|
+
properties:
|
|
20319
|
+
success:
|
|
20320
|
+
type: object
|
|
20321
|
+
additionalProperties: true
|
|
20322
|
+
eventId:
|
|
20323
|
+
type: string
|
|
20324
|
+
required:
|
|
20325
|
+
- success
|
|
20326
|
+
- eventId
|
|
20327
|
+
SubmitInteractionError:
|
|
20328
|
+
type: object
|
|
20329
|
+
properties:
|
|
20330
|
+
error:
|
|
20331
|
+
type: string
|
|
20332
|
+
enum:
|
|
20333
|
+
- FORBIDDEN
|
|
20334
|
+
required:
|
|
20335
|
+
- error
|
|
20278
20336
|
Job:
|
|
20279
20337
|
type: object
|
|
20280
20338
|
properties:
|