@proveanything/smartlinks 1.6.7 → 1.7.0

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,437 @@
1
+ # Container Tracking
2
+
3
+ > Physical or logical groupings with hierarchical nesting, item membership, and attestation history.
4
+
5
+ ---
6
+
7
+ ## Table of Contents
8
+
9
+ - [Overview](#overview)
10
+ - [TypeScript Interfaces](#typescript-interfaces)
11
+ - [SDK Usage](#sdk-usage)
12
+ - [Admin — Containers](#admin--containers)
13
+ - [Admin — Item Membership](#admin--item-membership)
14
+ - [Public — Read-Only](#public--read-only)
15
+ - [REST API Reference](#rest-api-reference)
16
+ - [Attestations on Containers](#attestations-on-containers)
17
+ - [Cold-Chain Tracking Example](#cold-chain-tracking-example)
18
+ - [Design Notes](#design-notes)
19
+
20
+ ---
21
+
22
+ ## Overview
23
+
24
+ A **container** is any physical or logical grouping that can hold items and accumulate a history. Examples:
25
+
26
+ | `containerType` | What it represents |
27
+ |---|---|
28
+ | `'pallet'` | A pallet of goods |
29
+ | `'fridge'` | A refrigerated unit |
30
+ | `'cask'` | A whiskey cask |
31
+ | `'shipping_container'` | A maritime shipping container |
32
+ | `'warehouse'` | A storage facility |
33
+ | `'ship'` | A vessel carrying other containers |
34
+
35
+ Containers support:
36
+
37
+ - **Hierarchical nesting** — a ship can contain shipping containers, which contain pallets, which contain individual items.
38
+ - **Item membership** — any `tag`, `proof`, `serial`, `order_item`, or nested `container` can be placed in and removed from a container. Membership is tracked with timestamps so the full history is available.
39
+ - **Attestations** — measurements and facts (temperature readings, ABV checks, condition reports, etc.) can be appended to any container using the `attestations` namespace.
40
+ - **Soft-delete** — deleting a container only sets `deletedAt`; the record and full item history are preserved.
41
+
42
+ ---
43
+
44
+ ## TypeScript Interfaces
45
+
46
+ ```typescript
47
+ type ContainerStatus = 'active' | 'archived' | string
48
+ type ContainerItemType = 'tag' | 'proof' | 'serial' | 'order_item' | 'container'
49
+
50
+ interface Container {
51
+ id: string
52
+ orgId: string
53
+ collectionId: string
54
+ containerType: string // 'pallet' | 'fridge' | 'cask' | …
55
+ ref?: string // Human-readable identifier / barcode
56
+ name?: string
57
+ description?: string
58
+ status: ContainerStatus // default 'active'
59
+ metadata?: Record<string, any>
60
+ parentContainerId?: string // null = top-level
61
+ children?: Container[] // Populated when ?tree=true
62
+ items?: ContainerItem[] // Populated when ?includeContents=true
63
+ createdAt: string
64
+ updatedAt: string
65
+ deletedAt?: string
66
+ }
67
+
68
+ interface ContainerItem {
69
+ id: string
70
+ orgId: string
71
+ containerId: string
72
+ collectionId?: string
73
+ itemType: ContainerItemType
74
+ itemId: string
75
+ productId?: string
76
+ proofId?: string
77
+ addedAt: string
78
+ removedAt?: string // null = currently inside; present in history view
79
+ metadata?: Record<string, any>
80
+ }
81
+
82
+ interface CreateContainerInput {
83
+ containerType: string // required
84
+ ref?: string
85
+ name?: string
86
+ description?: string
87
+ status?: ContainerStatus
88
+ metadata?: Record<string, any>
89
+ parentContainerId?: string
90
+ }
91
+
92
+ interface UpdateContainerInput {
93
+ containerType?: string
94
+ ref?: string
95
+ name?: string
96
+ description?: string
97
+ status?: ContainerStatus
98
+ metadata?: Record<string, any>
99
+ parentContainerId?: string | null // null promotes to top-level
100
+ }
101
+
102
+ interface AddContainerItemsInput {
103
+ items: Array<{
104
+ itemType: ContainerItemType // required
105
+ itemId: string // required
106
+ productId?: string
107
+ proofId?: string
108
+ metadata?: Record<string, any>
109
+ }>
110
+ }
111
+
112
+ interface RemoveContainerItemsInput {
113
+ ids: string[] // ContainerItem UUIDs to soft-remove
114
+ }
115
+ ```
116
+
117
+ ---
118
+
119
+ ## SDK Usage
120
+
121
+ Import via the top-level SDK export:
122
+
123
+ ```typescript
124
+ import { containers } from '@proveanything/smartlinks'
125
+ ```
126
+
127
+ ### Admin — Containers
128
+
129
+ #### Create a container
130
+
131
+ ```typescript
132
+ const cask = await containers.create('coll_123', {
133
+ containerType: 'cask',
134
+ ref: 'CASK-0042',
135
+ name: 'Cask 42 — Single Malt',
136
+ metadata: { distilleryYear: 2019, capacityLitres: 200 },
137
+ })
138
+ ```
139
+
140
+ #### List containers
141
+
142
+ ```typescript
143
+ // All active pallets
144
+ const { containers: pallets } = await containers.list('coll_123', {
145
+ containerType: 'pallet',
146
+ status: 'active',
147
+ limit: 50,
148
+ })
149
+
150
+ // Top-level containers only (no parent)
151
+ const { containers: roots } = await containers.list('coll_123', { topLevel: true })
152
+ ```
153
+
154
+ #### Get a container — flat, tree, or with contents
155
+
156
+ ```typescript
157
+ // Flat (default)
158
+ const cask = await containers.get('coll_123', 'cask-uuid')
159
+
160
+ // Full hierarchy tree (3 levels deep) with current contents
161
+ const tree = await containers.get('coll_123', 'warehouse-uuid', {
162
+ tree: true,
163
+ treeDepth: 3,
164
+ includeContents: true,
165
+ })
166
+ ```
167
+
168
+ #### Find containers currently holding an item
169
+
170
+ ```typescript
171
+ const { containers: holding } = await containers.findForItem('coll_123', {
172
+ itemType: 'proof',
173
+ itemId: 'proof-uuid',
174
+ })
175
+ ```
176
+
177
+ #### Update a container
178
+
179
+ ```typescript
180
+ const updated = await containers.update('coll_123', 'cask-uuid', {
181
+ status: 'archived',
182
+ metadata: { bottledAt: '2025-04-01' },
183
+ })
184
+
185
+ // Promote to top-level (remove parent)
186
+ await containers.update('coll_123', 'cask-uuid', { parentContainerId: null })
187
+ ```
188
+
189
+ #### Delete (soft) a container
190
+
191
+ ```typescript
192
+ await containers.remove('coll_123', 'cask-uuid')
193
+ // deletedAt is set; record remains queryable by admins
194
+ ```
195
+
196
+ ### Admin — Item Membership
197
+
198
+ #### List current contents
199
+
200
+ ```typescript
201
+ const { items } = await containers.listItems('coll_123', 'pallet-uuid')
202
+ ```
203
+
204
+ #### List full membership history (including removed items)
205
+
206
+ ```typescript
207
+ const { items: history } = await containers.listItems('coll_123', 'pallet-uuid', {
208
+ history: true,
209
+ })
210
+ // history includes items with removedAt !== null
211
+ ```
212
+
213
+ #### Add items to a container
214
+
215
+ ```typescript
216
+ const { items } = await containers.addItems('coll_123', 'pallet-uuid', {
217
+ items: [
218
+ { itemType: 'tag', itemId: 'NFC-00AABBCC' },
219
+ { itemType: 'proof', itemId: 'proof-uuid', productId: 'product-id' },
220
+ { itemType: 'container', itemId: 'inner-crate-uuid' },
221
+ ],
222
+ })
223
+ ```
224
+
225
+ #### Remove items from a container (soft)
226
+
227
+ ```typescript
228
+ const result = await containers.removeItems('coll_123', 'pallet-uuid', {
229
+ ids: ['container-item-uuid-1', 'container-item-uuid-2'],
230
+ })
231
+ console.log(`Removed ${result.removedCount} items`)
232
+ ```
233
+
234
+ ### Public — Read-Only
235
+
236
+ ```typescript
237
+ // List (excludes soft-deleted and non-public containers)
238
+ const { containers: list } = await containers.publicList('coll_123', {
239
+ containerType: 'cask',
240
+ })
241
+
242
+ // Get with tree
243
+ const tree = await containers.publicGet('coll_123', 'warehouse-uuid', {
244
+ tree: true,
245
+ })
246
+
247
+ // Current contents only (no history on public side)
248
+ const { items } = await containers.publicListItems('coll_123', 'cask-uuid')
249
+ ```
250
+
251
+ ---
252
+
253
+ ## REST API Reference
254
+
255
+ ### Admin endpoints
256
+
257
+ ```
258
+ POST /api/v1/admin/collection/:collectionId/containers
259
+ GET /api/v1/admin/collection/:collectionId/containers
260
+ GET /api/v1/admin/collection/:collectionId/containers/find-for-item
261
+ GET /api/v1/admin/collection/:collectionId/containers/:containerId
262
+ PATCH /api/v1/admin/collection/:collectionId/containers/:containerId
263
+ DELETE /api/v1/admin/collection/:collectionId/containers/:containerId
264
+ GET /api/v1/admin/collection/:collectionId/containers/:containerId/items
265
+ POST /api/v1/admin/collection/:collectionId/containers/:containerId/items
266
+ DELETE /api/v1/admin/collection/:collectionId/containers/:containerId/items
267
+ ```
268
+
269
+ #### GET /containers query parameters
270
+
271
+ | Parameter | Description |
272
+ |---|---|
273
+ | `containerType` | Filter by type string |
274
+ | `status` | Filter by status |
275
+ | `ref` | Filter by reference |
276
+ | `parentContainerId` | Filter by parent UUID |
277
+ | `topLevel` | `true` to return only root containers |
278
+ | `limit` | Default `100` |
279
+ | `offset` | Default `0` |
280
+
281
+ #### GET /containers/:id query parameters
282
+
283
+ | Parameter | Description |
284
+ |---|---|
285
+ | `tree` | `true` to recursively embed child containers |
286
+ | `treeDepth` | Max nesting depth (default: unlimited) |
287
+ | `includeContents` | `true` to embed current items |
288
+
289
+ #### GET /containers/:id/items query parameters
290
+
291
+ | Parameter | Description |
292
+ |---|---|
293
+ | `history` | `true` to include removed items |
294
+ | `limit` | Default `100` |
295
+ | `offset` | Default `0` |
296
+
297
+ #### POST /containers/:id/items body
298
+
299
+ ```json
300
+ {
301
+ "items": [
302
+ { "itemType": "tag", "itemId": "NFC-00AABBCC" },
303
+ { "itemType": "proof", "itemId": "proof-uuid", "productId": "product-id" }
304
+ ]
305
+ }
306
+ ```
307
+
308
+ #### DELETE /containers/:id/items body
309
+
310
+ ```json
311
+ { "ids": ["container-item-uuid-1", "container-item-uuid-2"] }
312
+ ```
313
+
314
+ ### Public endpoints
315
+
316
+ ```
317
+ GET /api/v1/public/collection/:collectionId/containers
318
+ GET /api/v1/public/collection/:collectionId/containers/:containerId
319
+ GET /api/v1/public/collection/:collectionId/containers/:containerId/items
320
+ ```
321
+
322
+ Same query parameters as admin (minus `history`). Soft-deleted containers and containers with `metadata.publicListing === false` are excluded from list results.
323
+
324
+ ---
325
+
326
+ ## Attestations on Containers
327
+
328
+ Attestations (sensor readings, condition reports, etc.) are managed via the `attestations` namespace. The admin router does **not** expose attestation sub-routes — use the standalone `/admin/.../attestations` router for all admin attestation access.
329
+
330
+ ```typescript
331
+ import { attestations } from '@proveanything/smartlinks'
332
+
333
+ // Record a temperature reading against a cask
334
+ await attestations.create('coll_123', {
335
+ subjectType: 'container',
336
+ subjectId: 'cask-uuid',
337
+ attestationType: 'temperature',
338
+ recordedAt: new Date().toISOString(),
339
+ value: { celsius: 12.4 },
340
+ unit: '°C',
341
+ })
342
+
343
+ // Latest readings on all attestation types
344
+ const { latest } = await attestations.latest('coll_123', {
345
+ subjectType: 'container',
346
+ subjectId: 'cask-uuid',
347
+ })
348
+ ```
349
+
350
+ Public shortcuts pre-scoped to a container are also available:
351
+
352
+ ```typescript
353
+ const { latest } = await attestations.publicContainerLatest('coll_123', 'cask-uuid')
354
+ const { summary } = await attestations.publicContainerSummary('coll_123', 'cask-uuid', {
355
+ attestationType: 'temperature',
356
+ valueField: 'celsius',
357
+ groupBy: 'hour',
358
+ })
359
+ ```
360
+
361
+ ---
362
+
363
+ ## Cold-Chain Tracking Example
364
+
365
+ **Goal:** determine whether a refrigerated item was inside a fridge when the temperature exceeded a threshold.
366
+
367
+ ```typescript
368
+ import { containers, attestations } from '@proveanything/smartlinks'
369
+
370
+ const collectionId = 'coll_123'
371
+ const fridgeId = 'fridge-uuid'
372
+ const proofId = 'bottle-proof-uuid'
373
+ const threshold = 8 // °C
374
+
375
+ // 1. Get the full item membership history for the fridge
376
+ const { items: history } = await containers.listItems(collectionId, fridgeId, {
377
+ history: true,
378
+ })
379
+
380
+ // 2. Find the window(s) when our bottle was inside the fridge
381
+ const bottleIntervals = history
382
+ .filter(item => item.itemType === 'proof' && item.itemId === proofId)
383
+ .map(item => ({
384
+ addedAt: new Date(item.addedAt),
385
+ removedAt: item.removedAt ? new Date(item.removedAt) : new Date(),
386
+ }))
387
+
388
+ // 3. Get temperature attestations for the fridge
389
+ const { attestations: temps } = await attestations.list(collectionId, {
390
+ subjectType: 'container',
391
+ subjectId: fridgeId,
392
+ attestationType: 'temperature',
393
+ limit: 1000,
394
+ })
395
+
396
+ // 4. Cross-reference
397
+ const excursions = temps.filter(a => {
398
+ const celsius = a.value?.celsius as number
399
+ const recorded = new Date(a.recordedAt)
400
+ if (celsius <= threshold) return false
401
+ return bottleIntervals.some(
402
+ ({ addedAt, removedAt }) => recorded >= addedAt && recorded <= removedAt
403
+ )
404
+ })
405
+
406
+ if (excursions.length > 0) {
407
+ console.warn(
408
+ `⚠️ ${excursions.length} temperature excursion(s) recorded while the bottle was in the fridge.`
409
+ )
410
+ } else {
411
+ console.log('✅ No temperature excursions during the bottle\'s time in the fridge.')
412
+ }
413
+ ```
414
+
415
+ ---
416
+
417
+ ## Design Notes
418
+
419
+ ### Soft-delete
420
+
421
+ `DELETE /containers/:id` only sets `deletedAt`. The container and its full item history remain queryable by admins. The public API automatically excludes deleted containers from all responses.
422
+
423
+ ### Membership history
424
+
425
+ `ContainerItem` records are never deleted. When an item is removed, `removedAt` is set. This means you always have a full audit trail of what was in a container and when, which is essential for provenance and compliance use-cases.
426
+
427
+ ### Public visibility
428
+
429
+ Containers with `metadata.publicListing === false` are excluded from public list endpoints but remain accessible by direct ID if the caller knows the UUID. Use this to hide containers from general browsing while still allowing deep-link access.
430
+
431
+ ### Nesting
432
+
433
+ Containers can be nested arbitrarily. A container with `containerType='container'` in the `items` list is a child container. The `findForItem` endpoint performs a flat lookup across all containers — it does not traverse the hierarchy.
434
+
435
+ ### Attestation ownership for containers
436
+
437
+ Owner elevation on attestation public endpoints resolves ownership via `container.metadata.proofId`. If you want individual users to receive owner-tier attestation data for a container, set `metadata.proofId` to a proof they own.
@@ -32,7 +32,7 @@ Deep-linkable states come from **two sources** depending on their nature:
32
32
 
33
33
  | Source | What goes here | When it changes |
34
34
  |--------|---------------|-----------------|
35
- | **App manifest** (`app.admin.json`) | Fixed routes built into the app | Only when the app itself is updated |
35
+ | **App manifest** (`app.manifest.json`) | Fixed routes built into the app | Only when the app itself is updated |
36
36
  | **App config** (`appConfig.linkable`) | Content-driven entries that vary by collection | When admins create, remove, or rename content |
37
37
 
38
38
  Consumers merge both sources to get the full set of navigable states for an app.
@@ -40,7 +40,7 @@ Consumers merge both sources to get the full set of navigable states for an app.
40
40
  ```text
41
41
  ┌─────────────────────────────────────────────────────────────────────────┐
42
42
  │ App Definition (build time) │
43
- │ app.admin.json → "linkable": [
43
+ │ app.manifest.json → "linkable": [
44
44
  │ { "title": "Gallery", "path": "/gallery" }, │
45
45
  │ { "title": "Settings", "path": "/settings" } │
46
46
  │ ] │
@@ -74,7 +74,7 @@ Consumers merge both sources to get the full set of navigable states for an app.
74
74
 
75
75
  ### Static Links — App Manifest
76
76
 
77
- Static links are routes that are **built into the app itself** — they exist regardless of what content admins have created. They belong in `app.admin.json` as a top-level `linkable` array:
77
+ Static links are routes that are **built into the app itself** — they exist regardless of what content admins have created. They belong in `app.manifest.json` as a top-level `linkable` array, as a peer to `widgets` and `containers`:
78
78
 
79
79
  ```json
80
80
  {
@@ -172,7 +172,7 @@ interface DeepLinkEntry {
172
172
 
173
173
  **An app with both static routes and dynamic content pages:**
174
174
 
175
- `app.admin.json` — declare static routes once, at build time:
175
+ `app.manifest.json` — declare static routes once, at build time:
176
176
  ```json
177
177
  {
178
178
  "linkable": [
@@ -441,7 +441,7 @@ if (entry) {
441
441
  ```typescript
442
442
  /**
443
443
  * A single navigable state exposed by a SmartLinks app.
444
- * Used in both app.admin.json `linkable` (static) and appConfig `linkable` (dynamic).
444
+ * Used in both app.manifest.json `linkable` (static) and appConfig `linkable` (dynamic).
445
445
  */
446
446
  export interface DeepLinkEntry {
447
447
  /** Human-readable label shown in menus and offered to AI agents */
@@ -469,7 +469,7 @@ export type DeepLinkRegistry = DeepLinkEntry[];
469
469
 
470
470
  ### Rules
471
471
 
472
- 1. **Static routes belong in the manifest** — if a route exists regardless of content, declare it in `app.admin.json`. Do not write it to `appConfig` on first run.
472
+ 1. **Static routes belong in the manifest** — if a route exists regardless of content, declare it in `app.manifest.json`. Do not write it to `appConfig` on first run.
473
473
  2. **`appConfig.linkable` is for dynamic content only** — it should contain entries generated from, and varying with, the app's data.
474
474
  3. **`linkable` is reserved** — do not use this key for other purposes in either the manifest or `appConfig`.
475
475
  4. **No platform context params in entries** — `collectionId`, `appId`, `productId`, `proofId`, `lang`, `theme` are injected by the platform automatically.