@proveanything/smartlinks 1.4.7 → 1.5.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.
- package/dist/api/appObjects.d.ts +127 -0
- package/dist/api/appObjects.js +257 -0
- package/dist/api/appRecord.d.ts +0 -6
- package/dist/api/appRecord.js +1 -29
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/cache.js +21 -0
- package/dist/docs/API_SUMMARY.md +468 -13
- package/dist/docs/app-objects.md +954 -0
- package/dist/docs/caching.md +186 -0
- package/dist/http.d.ts +6 -0
- package/dist/http.js +34 -0
- package/dist/types/appObjects.d.ts +371 -0
- package/dist/types/appObjects.js +2 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/docs/API_SUMMARY.md +468 -13
- package/docs/app-objects.md +954 -0
- package/docs/caching.md +186 -0
- package/package.json +1 -1
|
@@ -0,0 +1,954 @@
|
|
|
1
|
+
# App Objects: Cases, Threads, and Records
|
|
2
|
+
|
|
3
|
+
This guide covers the three generic app-scoped object types that apps can use as flexible building blocks for different use cases: **Cases**, **Threads**, and **Records**.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Overview
|
|
8
|
+
|
|
9
|
+
SmartLinks provides three generic data models scoped to your app that can be adapted for countless scenarios. Think of them as configurable primitives that you shape to fit your needs:
|
|
10
|
+
|
|
11
|
+
- **Cases** — Track issues, requests, or tasks that need resolution
|
|
12
|
+
- **Threads** — Manage discussions, comments, or any reply-based content
|
|
13
|
+
- **Records** — Store structured data with flexible lifecycles
|
|
14
|
+
|
|
15
|
+
Each object type supports:
|
|
16
|
+
- **JSONB zones** (`data`, `owner`, `admin`) for granular access control
|
|
17
|
+
- **Visibility levels** (`public`, `owner`, `admin`) for content exposure
|
|
18
|
+
- **Flexible schemas** — store any JSON in the zone fields
|
|
19
|
+
- **Admin and public endpoints** for different caller contexts
|
|
20
|
+
- **Rich querying** with filters, sorting, pagination, and aggregations
|
|
21
|
+
|
|
22
|
+
```text
|
|
23
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
24
|
+
│ Your SmartLinks App │
|
|
25
|
+
│ │
|
|
26
|
+
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
|
27
|
+
│ │ Cases │ │ Threads │ │ Records │ │
|
|
28
|
+
│ │ │ │ │ │ │ │
|
|
29
|
+
│ │ • Support │ │ • Comments │ │ • Bookings │ │
|
|
30
|
+
│ │ • Warranty │ │ • Q&A │ │ • Licenses │ │
|
|
31
|
+
│ │ • Feedback │ │ • Reviews │ │ • Visits │ │
|
|
32
|
+
│ │ • RMA │ │ • Forum │ │ • Events │ │
|
|
33
|
+
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
|
34
|
+
│ │
|
|
35
|
+
│ All scoped to: /collection/:cId/app/:appId │
|
|
36
|
+
└─────────────────────────────────────────────────────────────────┘
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## The JSONB Zone Model
|
|
42
|
+
|
|
43
|
+
All three object types use a three-tier access model with JSONB fields:
|
|
44
|
+
|
|
45
|
+
| Zone | Visible to | Writable by | Use Case |
|
|
46
|
+
|---------|-------------------|-------------------|-----------------------------------|
|
|
47
|
+
| `data` | public, owner, admin | public, owner, admin | Shared public information |
|
|
48
|
+
| `owner` | owner, admin | owner, admin | User-specific private data |
|
|
49
|
+
| `admin` | admin | admin | Internal notes, sensitive data |
|
|
50
|
+
|
|
51
|
+
### How Zones Work
|
|
52
|
+
|
|
53
|
+
Zones are **automatically filtered** based on the caller's role:
|
|
54
|
+
|
|
55
|
+
```typescript
|
|
56
|
+
// Public endpoint caller sees:
|
|
57
|
+
{
|
|
58
|
+
id: 'case_123',
|
|
59
|
+
status: 'open',
|
|
60
|
+
data: { issue: 'Screen cracked', photos: [...] },
|
|
61
|
+
// owner and admin zones stripped
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Owner (authenticated contact) sees:
|
|
65
|
+
{
|
|
66
|
+
id: 'case_123',
|
|
67
|
+
status: 'open',
|
|
68
|
+
data: { issue: 'Screen cracked', photos: [...] },
|
|
69
|
+
owner: { shippingAddress: '...', preference: 'email' },
|
|
70
|
+
// admin zone stripped
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Admin sees everything:
|
|
74
|
+
{
|
|
75
|
+
id: 'case_123',
|
|
76
|
+
status: 'open',
|
|
77
|
+
data: { issue: 'Screen cracked', photos: [...] },
|
|
78
|
+
owner: { shippingAddress: '...', preference: 'email' },
|
|
79
|
+
admin: { internalNotes: 'Escalate to tier 2', cost: 45.00 }
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**Key insight:** The server strips zones before returning objects. You don't need to worry about accidentally leaking `admin` data — it's never sent to non-admin callers.
|
|
84
|
+
|
|
85
|
+
### Zone Writing Rules
|
|
86
|
+
|
|
87
|
+
- **Non-admin callers** attempting to write to the `admin` zone are silently ignored
|
|
88
|
+
- **Public callers** can write to `data` and `owner` (if visibility allows)
|
|
89
|
+
- **Admins** can write to all three zones
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Visibility Levels
|
|
94
|
+
|
|
95
|
+
Each object has a `visibility` field that controls who can access it on **public endpoints**:
|
|
96
|
+
|
|
97
|
+
| Visibility | Public Endpoint Behavior |
|
|
98
|
+
|------------|--------------------------------------------------|
|
|
99
|
+
| `public` | Anyone can read (even anonymous) |
|
|
100
|
+
| `owner` | Only the owning contact can read |
|
|
101
|
+
| `admin` | Never visible on public endpoints (404) |
|
|
102
|
+
|
|
103
|
+
**Admin endpoints** always return all objects regardless of visibility.
|
|
104
|
+
|
|
105
|
+
### Typical Patterns
|
|
106
|
+
|
|
107
|
+
```typescript
|
|
108
|
+
// Public discussion thread
|
|
109
|
+
await threads.create(collectionId, appId, {
|
|
110
|
+
visibility: 'public',
|
|
111
|
+
title: 'How do I clean this product?',
|
|
112
|
+
body: { text: 'Looking for cleaning instructions...' }
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Private support case
|
|
116
|
+
await cases.create(collectionId, appId, {
|
|
117
|
+
visibility: 'owner', // Only this contact can see it
|
|
118
|
+
category: 'warranty',
|
|
119
|
+
data: { issue: 'Defective unit' },
|
|
120
|
+
owner: { serialNumber: 'ABC123' }
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Admin-only internal record
|
|
124
|
+
await records.create(collectionId, appId, {
|
|
125
|
+
visibility: 'admin', // Never appears on public endpoints
|
|
126
|
+
recordType: 'audit_log',
|
|
127
|
+
admin: { action: 'manual_refund', amount: 50.00 }
|
|
128
|
+
}, true); // admin = true
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Cases
|
|
134
|
+
|
|
135
|
+
**Cases** represent trackable issues, requests, or tasks that move through states and require resolution.
|
|
136
|
+
|
|
137
|
+
### When to Use Cases
|
|
138
|
+
|
|
139
|
+
- **Customer support tickets** — track issues from creation to resolution
|
|
140
|
+
- **Warranty claims** — manage claims with status, priority, and assignment
|
|
141
|
+
- **Feature requests** — collect and triage user feedback
|
|
142
|
+
- **RMA (Return Merchandise Authorization)** — handle product returns
|
|
143
|
+
- **Bug reports** — track defects from user submissions
|
|
144
|
+
- **Service requests** — manage appointments, repairs, installations
|
|
145
|
+
|
|
146
|
+
### Key Features
|
|
147
|
+
|
|
148
|
+
- **Status lifecycle** — `'open'` → `'in-progress'` → `'resolved'` → `'closed'` (or custom statuses)
|
|
149
|
+
- **Priority levels** — numerical priority for sorting/escalation
|
|
150
|
+
- **Categories** — group cases by type (warranty, bug, feature, etc.)
|
|
151
|
+
- **Assignment** — `assignedTo` field for routing to team members
|
|
152
|
+
- **History tracking** — append timestamped entries to `admin.history` or `owner.history`
|
|
153
|
+
- **Closing metrics** — track `closedAt` to measure resolution time
|
|
154
|
+
|
|
155
|
+
### Example: Warranty Claims
|
|
156
|
+
|
|
157
|
+
```typescript
|
|
158
|
+
import { cases } from '@proveanything/smartlinks';
|
|
159
|
+
|
|
160
|
+
// Customer submits a warranty claim (public endpoint)
|
|
161
|
+
const claim = await cases.create(collectionId, appId, {
|
|
162
|
+
visibility: 'owner',
|
|
163
|
+
category: 'warranty',
|
|
164
|
+
status: 'open',
|
|
165
|
+
priority: 2,
|
|
166
|
+
productId: product.id,
|
|
167
|
+
proofId: proof.id,
|
|
168
|
+
contactId: user.contactId,
|
|
169
|
+
data: {
|
|
170
|
+
issue: 'Screen flickering after 3 months',
|
|
171
|
+
photos: ['https://...', 'https://...']
|
|
172
|
+
},
|
|
173
|
+
owner: {
|
|
174
|
+
purchaseDate: '2025-11-15',
|
|
175
|
+
serialNumber: 'SN-7738291',
|
|
176
|
+
preferredContact: 'email'
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Admin reviews and assigns (admin endpoint)
|
|
181
|
+
await cases.update(collectionId, appId, claim.id, {
|
|
182
|
+
assignedTo: 'user_jane_support',
|
|
183
|
+
priority: 3, // escalate
|
|
184
|
+
admin: {
|
|
185
|
+
internalNotes: 'Likely hardware defect, approve replacement'
|
|
186
|
+
}
|
|
187
|
+
}, true); // admin = true
|
|
188
|
+
|
|
189
|
+
// Admin appends to history
|
|
190
|
+
await cases.appendHistory(collectionId, appId, claim.id, {
|
|
191
|
+
entry: {
|
|
192
|
+
action: 'approved_replacement',
|
|
193
|
+
agent: 'Jane',
|
|
194
|
+
tracking: 'UPS-123456789'
|
|
195
|
+
},
|
|
196
|
+
historyTarget: 'owner', // visible to customer
|
|
197
|
+
status: 'resolved'
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Get case summary stats (admin)
|
|
201
|
+
const summary = await cases.summary(collectionId, appId, {
|
|
202
|
+
period: { from: '2026-01-01', to: '2026-02-28' }
|
|
203
|
+
});
|
|
204
|
+
// Returns: { total: 142, byStatus: { open: 12, resolved: 130 }, ... }
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### Use Case: Support Dashboard
|
|
208
|
+
|
|
209
|
+
Build a live support dashboard showing open cases by priority:
|
|
210
|
+
|
|
211
|
+
```typescript
|
|
212
|
+
const openCases = await cases.list(collectionId, appId, {
|
|
213
|
+
status: 'open',
|
|
214
|
+
sort: 'priority:desc',
|
|
215
|
+
limit: 50
|
|
216
|
+
}, true);
|
|
217
|
+
|
|
218
|
+
// Aggregate by category
|
|
219
|
+
const stats = await cases.aggregate(collectionId, appId, {
|
|
220
|
+
filters: { status: 'open' },
|
|
221
|
+
groupBy: ['category', 'priority'],
|
|
222
|
+
metrics: ['count']
|
|
223
|
+
}, true);
|
|
224
|
+
|
|
225
|
+
// Time series: cases created per week
|
|
226
|
+
const trend = await cases.aggregate(collectionId, appId, {
|
|
227
|
+
timeSeriesField: 'created_at',
|
|
228
|
+
timeSeriesInterval: 'week',
|
|
229
|
+
metrics: ['count']
|
|
230
|
+
}, true);
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
---
|
|
234
|
+
|
|
235
|
+
## Threads
|
|
236
|
+
|
|
237
|
+
**Threads** represent discussions, comments, or any content that accumulates replies over time.
|
|
238
|
+
|
|
239
|
+
### When to Use Threads
|
|
240
|
+
|
|
241
|
+
- **Product Q&A** — questions and answers about products
|
|
242
|
+
- **Community forums** — discussions grouped by topic
|
|
243
|
+
- **Comments** — on products, proofs, or other resources
|
|
244
|
+
- **Review discussions** — follow-up questions on reviews
|
|
245
|
+
- **Feedback threads** — ongoing conversations about features
|
|
246
|
+
- **Support chat** — lightweight message threads
|
|
247
|
+
|
|
248
|
+
### Key Features
|
|
249
|
+
|
|
250
|
+
- **Reply tracking** — `replies` array with timestamped entries
|
|
251
|
+
- **Reply count** — auto-incremented `replyCount` and `lastReplyAt`
|
|
252
|
+
- **Slugs** — optional URL-friendly slug for pretty URLs
|
|
253
|
+
- **Tags** — JSONB array of tags for categorization
|
|
254
|
+
- **Parent linking** — `parentType` + `parentId` to attach to products, proofs, etc.
|
|
255
|
+
- **Author metadata** — track `authorId` and `authorType`
|
|
256
|
+
|
|
257
|
+
### Example: Product Q&A
|
|
258
|
+
|
|
259
|
+
```typescript
|
|
260
|
+
import { threads } from '@proveanything/smartlinks';
|
|
261
|
+
|
|
262
|
+
// Customer asks a question (public endpoint)
|
|
263
|
+
const question = await threads.create(collectionId, appId, {
|
|
264
|
+
visibility: 'public',
|
|
265
|
+
slug: 'how-to-clean-leather',
|
|
266
|
+
title: 'How do I clean leather without damaging it?',
|
|
267
|
+
status: 'open',
|
|
268
|
+
authorId: user.contactId,
|
|
269
|
+
authorType: 'customer',
|
|
270
|
+
productId: product.id,
|
|
271
|
+
body: {
|
|
272
|
+
text: 'I spilled coffee on my leather bag. What cleaner is safe to use?'
|
|
273
|
+
},
|
|
274
|
+
tags: ['cleaning', 'leather', 'care']
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// Another customer replies
|
|
278
|
+
await threads.reply(collectionId, appId, question.id, {
|
|
279
|
+
authorId: otherUser.contactId,
|
|
280
|
+
authorType: 'customer',
|
|
281
|
+
text: 'I use a mild soap and water solution. Works great!'
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// Admin (brand expert) replies
|
|
285
|
+
await threads.reply(collectionId, appId, question.id, {
|
|
286
|
+
authorId: 'user_expert_sarah',
|
|
287
|
+
authorType: 'brand_expert',
|
|
288
|
+
text: 'Our official leather care kit is perfect for this. Avoid harsh chemicals.',
|
|
289
|
+
productLink: 'prod_leather_care_kit'
|
|
290
|
+
}, true); // admin endpoint
|
|
291
|
+
|
|
292
|
+
// Admin marks as resolved
|
|
293
|
+
await threads.update(collectionId, appId, question.id, {
|
|
294
|
+
status: 'resolved'
|
|
295
|
+
}, true);
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
### Use Case: Forum-Style Discussions
|
|
299
|
+
|
|
300
|
+
List recent discussions with reply counts:
|
|
301
|
+
|
|
302
|
+
```typescript
|
|
303
|
+
// Get active threads
|
|
304
|
+
const activeThreads = await threads.list(collectionId, appId, {
|
|
305
|
+
status: 'open',
|
|
306
|
+
sort: 'lastReplyAt:desc',
|
|
307
|
+
limit: 20
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// Filter by tag
|
|
311
|
+
const cleaningThreads = await threads.list(collectionId, appId, {
|
|
312
|
+
tag: 'cleaning'
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// Aggregate: most active discussion topics
|
|
316
|
+
const topicStats = await threads.aggregate(collectionId, appId, {
|
|
317
|
+
groupBy: ['status'],
|
|
318
|
+
metrics: ['count', 'reply_count']
|
|
319
|
+
});
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
### Use Case: Product Comments
|
|
323
|
+
|
|
324
|
+
Attach comments to a specific product:
|
|
325
|
+
|
|
326
|
+
```typescript
|
|
327
|
+
// Create a comment thread for a product
|
|
328
|
+
await threads.create(collectionId, appId, {
|
|
329
|
+
visibility: 'public',
|
|
330
|
+
parentType: 'product',
|
|
331
|
+
parentId: product.id,
|
|
332
|
+
authorId: user.contactId,
|
|
333
|
+
body: { text: 'Love this product! Best purchase ever.' },
|
|
334
|
+
tags: ['positive']
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// List all comments for a product
|
|
338
|
+
const productComments = await threads.list(collectionId, appId, {
|
|
339
|
+
parentType: 'product',
|
|
340
|
+
parentId: product.id,
|
|
341
|
+
sort: 'createdAt:desc'
|
|
342
|
+
});
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
---
|
|
346
|
+
|
|
347
|
+
## Records
|
|
348
|
+
|
|
349
|
+
**Records** are the most flexible object type — use them for structured data with time-based lifecycles, hierarchies, or custom schemas.
|
|
350
|
+
|
|
351
|
+
### When to Use Records
|
|
352
|
+
|
|
353
|
+
- **Bookings/Reservations** — track appointments with start/end times
|
|
354
|
+
- **Licenses** — manage software licenses with expiration
|
|
355
|
+
- **Subscriptions** — track subscription status and renewal
|
|
356
|
+
- **Certifications** — store certifications with expiry dates
|
|
357
|
+
- **Events** — track event registrations and attendance
|
|
358
|
+
- **Usage logs** — record product usage metrics
|
|
359
|
+
- **Audit trails** — immutable logs of actions
|
|
360
|
+
- **Loyalty points** — track points earned/redeemed
|
|
361
|
+
|
|
362
|
+
### Key Features
|
|
363
|
+
|
|
364
|
+
- **Record types** — `recordType` field for categorization (required)
|
|
365
|
+
- **Time windows** — `startsAt` and `expiresAt` for time-based data
|
|
366
|
+
- **Parent linking** — attach to products, proofs, contacts, etc.
|
|
367
|
+
- **Author tracking** — `authorId` + `authorType`
|
|
368
|
+
- **Status lifecycle** — custom statuses (default `'active'`)
|
|
369
|
+
- **References** — optional `ref` field for external IDs
|
|
370
|
+
|
|
371
|
+
### Example: Product Registration
|
|
372
|
+
|
|
373
|
+
```typescript
|
|
374
|
+
import { records } from '@proveanything/smartlinks';
|
|
375
|
+
|
|
376
|
+
// Customer registers a product
|
|
377
|
+
const registration = await records.create(collectionId, appId, {
|
|
378
|
+
recordType: 'product_registration',
|
|
379
|
+
visibility: 'owner',
|
|
380
|
+
status: 'active',
|
|
381
|
+
productId: product.id,
|
|
382
|
+
proofId: proof.id,
|
|
383
|
+
contactId: user.contactId,
|
|
384
|
+
authorId: user.contactId,
|
|
385
|
+
authorType: 'customer',
|
|
386
|
+
startsAt: new Date().toISOString(),
|
|
387
|
+
expiresAt: new Date(Date.now() + 365*24*60*60*1000).toISOString(), // 1 year warranty
|
|
388
|
+
data: {
|
|
389
|
+
registrationNumber: 'REG-2026-1234',
|
|
390
|
+
purchaseDate: '2026-02-15',
|
|
391
|
+
retailer: 'Best Electronics'
|
|
392
|
+
},
|
|
393
|
+
owner: {
|
|
394
|
+
serialNumber: 'SN-9922736',
|
|
395
|
+
installDate: '2026-02-20',
|
|
396
|
+
location: 'Home office'
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
// List active registrations for a customer
|
|
401
|
+
const activeRegistrations = await records.list(collectionId, appId, {
|
|
402
|
+
contactId: user.contactId,
|
|
403
|
+
recordType: 'product_registration',
|
|
404
|
+
status: 'active'
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// Find expiring registrations (admin)
|
|
408
|
+
const expiringSoon = await records.list(collectionId, appId, {
|
|
409
|
+
recordType: 'product_registration',
|
|
410
|
+
expiresAt: `lte:${new Date(Date.now() + 30*24*60*60*1000).toISOString()}` // next 30 days
|
|
411
|
+
}, true);
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
### Example: Appointment Booking
|
|
415
|
+
|
|
416
|
+
```typescript
|
|
417
|
+
// Customer books a service appointment
|
|
418
|
+
const booking = await records.create(collectionId, appId, {
|
|
419
|
+
recordType: 'service_appointment',
|
|
420
|
+
visibility: 'owner',
|
|
421
|
+
contactId: user.contactId,
|
|
422
|
+
startsAt: '2026-03-15T10:00:00Z',
|
|
423
|
+
expiresAt: '2026-03-15T11:00:00Z', // 1-hour appointment
|
|
424
|
+
data: {
|
|
425
|
+
serviceType: 'installation',
|
|
426
|
+
location: 'Customer site',
|
|
427
|
+
technician: null // assigned later
|
|
428
|
+
},
|
|
429
|
+
owner: {
|
|
430
|
+
address: '123 Main St',
|
|
431
|
+
phone: '555-1234',
|
|
432
|
+
notes: 'Call before arrival'
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// Admin assigns technician
|
|
437
|
+
await records.update(collectionId, appId, booking.id, {
|
|
438
|
+
data: {
|
|
439
|
+
serviceType: 'installation',
|
|
440
|
+
location: 'Customer site',
|
|
441
|
+
technician: 'tech_john'
|
|
442
|
+
},
|
|
443
|
+
admin: {
|
|
444
|
+
cost: 150.00,
|
|
445
|
+
travelTime: 30
|
|
446
|
+
}
|
|
447
|
+
}, true);
|
|
448
|
+
|
|
449
|
+
// List today's appointments
|
|
450
|
+
const today = new Date().toISOString().split('T')[0];
|
|
451
|
+
const todaysAppointments = await records.list(collectionId, appId, {
|
|
452
|
+
recordType: 'service_appointment',
|
|
453
|
+
startsAt: `gte:${today}T00:00:00Z`,
|
|
454
|
+
sort: 'startsAt:asc'
|
|
455
|
+
}, true);
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
### Example: Usage Tracking
|
|
459
|
+
|
|
460
|
+
```typescript
|
|
461
|
+
// Log product usage (could be triggered by IoT device)
|
|
462
|
+
await records.create(collectionId, appId, {
|
|
463
|
+
recordType: 'usage_log',
|
|
464
|
+
visibility: 'admin',
|
|
465
|
+
productId: product.id,
|
|
466
|
+
proofId: proof.id,
|
|
467
|
+
startsAt: new Date().toISOString(),
|
|
468
|
+
data: {
|
|
469
|
+
metric: 'power_on',
|
|
470
|
+
duration: 3600, // seconds
|
|
471
|
+
location: 'geo:37.7749,-122.4194'
|
|
472
|
+
}
|
|
473
|
+
}, true);
|
|
474
|
+
|
|
475
|
+
// Aggregate usage metrics
|
|
476
|
+
const usageStats = await records.aggregate(collectionId, appId, {
|
|
477
|
+
filters: {
|
|
478
|
+
record_type: 'usage_log',
|
|
479
|
+
created_at: {
|
|
480
|
+
gte: '2026-02-01',
|
|
481
|
+
lte: '2026-02-28'
|
|
482
|
+
}
|
|
483
|
+
},
|
|
484
|
+
groupBy: ['product_id'],
|
|
485
|
+
metrics: ['count']
|
|
486
|
+
}, true);
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
---
|
|
490
|
+
|
|
491
|
+
## Public Create Policies
|
|
492
|
+
|
|
493
|
+
Control who can create objects on **public endpoints** using Firestore-based policies at:
|
|
494
|
+
`sites/{collectionId}/apps/{appId}.publicCreate`
|
|
495
|
+
|
|
496
|
+
### Policy Structure
|
|
497
|
+
|
|
498
|
+
```typescript
|
|
499
|
+
interface PublicCreatePolicy {
|
|
500
|
+
cases?: {
|
|
501
|
+
allow: {
|
|
502
|
+
anonymous?: boolean // allow unauthenticated users
|
|
503
|
+
authenticated?: boolean // allow authenticated contacts
|
|
504
|
+
}
|
|
505
|
+
enforce?: {
|
|
506
|
+
anonymous?: Partial<CreateCaseInput> // force these values for anon
|
|
507
|
+
authenticated?: Partial<CreateCaseInput> // force these values for auth
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
threads?: { /* same structure */ }
|
|
511
|
+
records?: { /* same structure */ }
|
|
512
|
+
}
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
### Example Policies
|
|
516
|
+
|
|
517
|
+
**Support tickets from anyone:**
|
|
518
|
+
|
|
519
|
+
```json
|
|
520
|
+
{
|
|
521
|
+
"cases": {
|
|
522
|
+
"allow": {
|
|
523
|
+
"anonymous": true,
|
|
524
|
+
"authenticated": true
|
|
525
|
+
},
|
|
526
|
+
"enforce": {
|
|
527
|
+
"anonymous": {
|
|
528
|
+
"visibility": "owner",
|
|
529
|
+
"status": "open",
|
|
530
|
+
"category": "support"
|
|
531
|
+
},
|
|
532
|
+
"authenticated": {
|
|
533
|
+
"visibility": "owner",
|
|
534
|
+
"status": "open"
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
**Public Q&A threads, authenticated only:**
|
|
542
|
+
|
|
543
|
+
```json
|
|
544
|
+
{
|
|
545
|
+
"threads": {
|
|
546
|
+
"allow": {
|
|
547
|
+
"anonymous": false,
|
|
548
|
+
"authenticated": true
|
|
549
|
+
},
|
|
550
|
+
"enforce": {
|
|
551
|
+
"authenticated": {
|
|
552
|
+
"visibility": "public",
|
|
553
|
+
"status": "open"
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
**No public record creation:**
|
|
561
|
+
|
|
562
|
+
```json
|
|
563
|
+
{
|
|
564
|
+
"records": {
|
|
565
|
+
"allow": {
|
|
566
|
+
"anonymous": false,
|
|
567
|
+
"authenticated": false
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
The `enforce` values are **merged over** the caller's request body, so you can lock down fields like `visibility`, `status`, or `category` regardless of what clients send.
|
|
574
|
+
|
|
575
|
+
---
|
|
576
|
+
|
|
577
|
+
## Aggregations and Analytics
|
|
578
|
+
|
|
579
|
+
All three object types support powerful aggregation queries for dashboards and reports.
|
|
580
|
+
|
|
581
|
+
### Aggregation Capabilities
|
|
582
|
+
|
|
583
|
+
```typescript
|
|
584
|
+
interface AggregateRequest {
|
|
585
|
+
filters?: {
|
|
586
|
+
status?: string
|
|
587
|
+
category?: string // cases only
|
|
588
|
+
record_type?: string // records only
|
|
589
|
+
product_id?: string
|
|
590
|
+
created_at?: { gte?: string; lte?: string }
|
|
591
|
+
closed_at?: '__notnull__' | { gte?: string; lte?: string } // cases
|
|
592
|
+
expires_at?: { lte?: string } // records
|
|
593
|
+
}
|
|
594
|
+
groupBy?: string[] // dimension breakdown
|
|
595
|
+
metrics?: string[] // calculated values
|
|
596
|
+
timeSeriesField?: string
|
|
597
|
+
timeSeriesInterval?: 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'
|
|
598
|
+
}
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
### Cases Aggregations
|
|
602
|
+
|
|
603
|
+
**Group by dimensions:**
|
|
604
|
+
`status`, `priority`, `category`, `assigned_to`, `product_id`, `contact_id`
|
|
605
|
+
|
|
606
|
+
**Metrics:**
|
|
607
|
+
`count`, `avg_close_time`, `p50_close_time`, `p95_close_time`
|
|
608
|
+
|
|
609
|
+
```typescript
|
|
610
|
+
// Average resolution time by category
|
|
611
|
+
const metrics = await cases.aggregate(collectionId, appId, {
|
|
612
|
+
filters: {
|
|
613
|
+
closed_at: '__notnull__'
|
|
614
|
+
},
|
|
615
|
+
groupBy: ['category'],
|
|
616
|
+
metrics: ['count', 'avg_close_time', 'p95_close_time']
|
|
617
|
+
}, true);
|
|
618
|
+
|
|
619
|
+
// Result:
|
|
620
|
+
// {
|
|
621
|
+
// groups: [
|
|
622
|
+
// { category: 'warranty', count: 45, avg_close_time_seconds: 7200, p95_close_time_seconds: 14400 },
|
|
623
|
+
// { category: 'support', count: 89, avg_close_time_seconds: 3600, p95_close_time_seconds: 10800 }
|
|
624
|
+
// ]
|
|
625
|
+
// }
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
### Threads Aggregations
|
|
629
|
+
|
|
630
|
+
**Group by dimensions:**
|
|
631
|
+
`status`, `author_type`, `product_id`, `visibility`, `contact_id`
|
|
632
|
+
|
|
633
|
+
**Metrics:**
|
|
634
|
+
`count`, `reply_count`
|
|
635
|
+
|
|
636
|
+
```typescript
|
|
637
|
+
// Most active discussion authors
|
|
638
|
+
const authorStats = await threads.aggregate(collectionId, appId, {
|
|
639
|
+
groupBy: ['author_type'],
|
|
640
|
+
metrics: ['count', 'reply_count']
|
|
641
|
+
});
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
### Records Aggregations
|
|
645
|
+
|
|
646
|
+
**Group by dimensions:**
|
|
647
|
+
`status`, `record_type`, `product_id`, `author_type`, `visibility`, `contact_id`
|
|
648
|
+
|
|
649
|
+
**Metrics:**
|
|
650
|
+
`count`
|
|
651
|
+
|
|
652
|
+
```typescript
|
|
653
|
+
// Bookings by status
|
|
654
|
+
const bookingStats = await records.aggregate(collectionId, appId, {
|
|
655
|
+
filters: {
|
|
656
|
+
record_type: 'service_appointment'
|
|
657
|
+
},
|
|
658
|
+
groupBy: ['status'],
|
|
659
|
+
metrics: ['count']
|
|
660
|
+
}, true);
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
### Time Series
|
|
664
|
+
|
|
665
|
+
Generate time-based charts:
|
|
666
|
+
|
|
667
|
+
```typescript
|
|
668
|
+
// Cases created per week
|
|
669
|
+
const casesTrend = await cases.aggregate(collectionId, appId, {
|
|
670
|
+
timeSeriesField: 'created_at',
|
|
671
|
+
timeSeriesInterval: 'week',
|
|
672
|
+
metrics: ['count']
|
|
673
|
+
}, true);
|
|
674
|
+
|
|
675
|
+
// Result:
|
|
676
|
+
// {
|
|
677
|
+
// timeSeries: [
|
|
678
|
+
// { bucket: '2026-W07', count: 23 },
|
|
679
|
+
// { bucket: '2026-W08', count: 31 },
|
|
680
|
+
// { bucket: '2026-W09', count: 28 }
|
|
681
|
+
// ]
|
|
682
|
+
// }
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
---
|
|
686
|
+
|
|
687
|
+
## Common Patterns
|
|
688
|
+
|
|
689
|
+
### Pattern: Related Data
|
|
690
|
+
|
|
691
|
+
Cases have a built-in `related()` endpoint to fetch associated threads and records:
|
|
692
|
+
|
|
693
|
+
```typescript
|
|
694
|
+
// Get all related content for a case
|
|
695
|
+
const related = await cases.related(collectionId, appId, caseId);
|
|
696
|
+
// Returns: { threads: [...], records: [...] }
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
For threads and records, use parent linking:
|
|
700
|
+
|
|
701
|
+
```typescript
|
|
702
|
+
// Create a thread about a case
|
|
703
|
+
await threads.create(collectionId, appId, {
|
|
704
|
+
parentType: 'case',
|
|
705
|
+
parentId: caseId,
|
|
706
|
+
body: { text: 'Follow-up discussion about this case' }
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
// List all threads for a case
|
|
710
|
+
const caseThreads = await threads.list(collectionId, appId, {
|
|
711
|
+
parentType: 'case',
|
|
712
|
+
parentId: caseId
|
|
713
|
+
});
|
|
714
|
+
```
|
|
715
|
+
|
|
716
|
+
### Pattern: Hierarchical Records
|
|
717
|
+
|
|
718
|
+
Use `parentType` and `parentId` to build hierarchies:
|
|
719
|
+
|
|
720
|
+
```typescript
|
|
721
|
+
// Parent record: subscription
|
|
722
|
+
const subscription = await records.create(collectionId, appId, {
|
|
723
|
+
recordType: 'subscription',
|
|
724
|
+
data: { plan: 'premium', billingCycle: 'monthly' }
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
// Child records: invoices
|
|
728
|
+
await records.create(collectionId, appId, {
|
|
729
|
+
recordType: 'invoice',
|
|
730
|
+
parentType: 'subscription',
|
|
731
|
+
parentId: subscription.id,
|
|
732
|
+
data: { amount: 29.99, period: '2026-02' }
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
// List all invoices for a subscription
|
|
736
|
+
const invoices = await records.list(collectionId, appId, {
|
|
737
|
+
recordType: 'invoice',
|
|
738
|
+
parentType: 'subscription',
|
|
739
|
+
parentId: subscription.id
|
|
740
|
+
});
|
|
741
|
+
```
|
|
742
|
+
|
|
743
|
+
### Pattern: Audit Trails
|
|
744
|
+
|
|
745
|
+
Use admin-only records to log changes:
|
|
746
|
+
|
|
747
|
+
```typescript
|
|
748
|
+
async function auditLog(action: string, details: any) {
|
|
749
|
+
await records.create(collectionId, appId, {
|
|
750
|
+
recordType: 'audit_log',
|
|
751
|
+
visibility: 'admin',
|
|
752
|
+
authorId: currentUser.id,
|
|
753
|
+
authorType: 'admin',
|
|
754
|
+
data: {
|
|
755
|
+
action,
|
|
756
|
+
timestamp: new Date().toISOString(),
|
|
757
|
+
...details
|
|
758
|
+
}
|
|
759
|
+
}, true);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// Usage
|
|
763
|
+
await auditLog('case_reassigned', {
|
|
764
|
+
caseId: 'case_123',
|
|
765
|
+
from: 'user_jane',
|
|
766
|
+
to: 'user_bob'
|
|
767
|
+
});
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
### Pattern: Notifications
|
|
771
|
+
|
|
772
|
+
Combine with the realtime API to notify users of changes:
|
|
773
|
+
|
|
774
|
+
```typescript
|
|
775
|
+
import { realtime } from '@proveanything/smartlinks';
|
|
776
|
+
|
|
777
|
+
// When a case is updated
|
|
778
|
+
await cases.update(collectionId, appId, caseId, { status: 'resolved' }, true);
|
|
779
|
+
|
|
780
|
+
// Notify the contact
|
|
781
|
+
await realtime.publish(collectionId, `contact:${contactId}`, {
|
|
782
|
+
type: 'case_resolved',
|
|
783
|
+
caseId,
|
|
784
|
+
message: 'Your support case has been resolved'
|
|
785
|
+
});
|
|
786
|
+
```
|
|
787
|
+
|
|
788
|
+
---
|
|
789
|
+
|
|
790
|
+
## Best Practices
|
|
791
|
+
|
|
792
|
+
### Use the Right Object Type
|
|
793
|
+
|
|
794
|
+
| Need | Use |
|
|
795
|
+
|------|-----|
|
|
796
|
+
| Track something that needs resolution | **Cases** |
|
|
797
|
+
| Build a discussion or comment system | **Threads** |
|
|
798
|
+
| Store time-sensitive or hierarchical data | **Records** |
|
|
799
|
+
|
|
800
|
+
### Zone Allocation Strategy
|
|
801
|
+
|
|
802
|
+
- **`data`** — Put information that's safe for anyone to see (even if `visibility` is `owner`)
|
|
803
|
+
- **`owner`** — Store user-specific preferences, addresses, contact info
|
|
804
|
+
- **`admin`** — Keep internal notes, costs, sensitive metadata
|
|
805
|
+
|
|
806
|
+
### Visibility Defaults
|
|
807
|
+
|
|
808
|
+
- **User-facing content** → `visibility: 'public'` (Q&A, reviews, forums)
|
|
809
|
+
- **Private user data** → `visibility: 'owner'` (support cases, bookings)
|
|
810
|
+
- **Internal data** → `visibility: 'admin'` (audit logs, analytics)
|
|
811
|
+
|
|
812
|
+
### Indexing and Performance
|
|
813
|
+
|
|
814
|
+
For high-volume queries, consider:
|
|
815
|
+
- Filter by `status`, `recordType`, or `category` to reduce result sets
|
|
816
|
+
- Use `limit` and `offset` for pagination (max 500 per page)
|
|
817
|
+
- Use aggregations instead of fetching all records and counting client-side
|
|
818
|
+
- Index commonly filtered fields in Firestore if you add custom indexes
|
|
819
|
+
|
|
820
|
+
### Status Conventions
|
|
821
|
+
|
|
822
|
+
While statuses are free-form strings, consider standard conventions:
|
|
823
|
+
|
|
824
|
+
**Cases:** `open`, `in_progress`, `waiting_customer`, `resolved`, `closed`
|
|
825
|
+
**Threads:** `open`, `closed`, `locked`, `archived`
|
|
826
|
+
**Records:** `active`, `inactive`, `expired`, `cancelled`
|
|
827
|
+
|
|
828
|
+
---
|
|
829
|
+
|
|
830
|
+
## Example: Complete Support System
|
|
831
|
+
|
|
832
|
+
Here's a full workflow combining all three object types:
|
|
833
|
+
|
|
834
|
+
```typescript
|
|
835
|
+
import { cases, threads, records } from '@proveanything/smartlinks';
|
|
836
|
+
|
|
837
|
+
// 1. Customer submits a warranty claim (case)
|
|
838
|
+
const claim = await cases.create(collectionId, appId, {
|
|
839
|
+
visibility: 'owner',
|
|
840
|
+
category: 'warranty',
|
|
841
|
+
status: 'open',
|
|
842
|
+
priority: 2,
|
|
843
|
+
productId,
|
|
844
|
+
proofId,
|
|
845
|
+
contactId,
|
|
846
|
+
data: { issue: 'Defective battery' },
|
|
847
|
+
owner: { serialNumber: 'SN-123' }
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
// 2. Customer starts a discussion about the claim (thread)
|
|
851
|
+
const discussion = await threads.create(collectionId, appId, {
|
|
852
|
+
visibility: 'owner',
|
|
853
|
+
parentType: 'case',
|
|
854
|
+
parentId: claim.id,
|
|
855
|
+
title: 'Questions about my warranty claim',
|
|
856
|
+
body: { text: 'How long will the replacement take?' }
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
// 3. Admin replies to the discussion
|
|
860
|
+
await threads.reply(collectionId, appId, discussion.id, {
|
|
861
|
+
authorId: 'admin_sarah',
|
|
862
|
+
authorType: 'support_agent',
|
|
863
|
+
text: 'We'll ship a replacement within 2 business days'
|
|
864
|
+
}, true);
|
|
865
|
+
|
|
866
|
+
// 4. Admin approves and creates a shipping record
|
|
867
|
+
const shipment = await records.create(collectionId, appId, {
|
|
868
|
+
recordType: 'shipment',
|
|
869
|
+
parentType: 'case',
|
|
870
|
+
parentId: claim.id,
|
|
871
|
+
data: {
|
|
872
|
+
carrier: 'UPS',
|
|
873
|
+
tracking: 'UPS-123456789',
|
|
874
|
+
estimatedDelivery: '2026-02-28'
|
|
875
|
+
},
|
|
876
|
+
owner: {
|
|
877
|
+
shippingAddress: '123 Main St'
|
|
878
|
+
},
|
|
879
|
+
admin: {
|
|
880
|
+
cost: 25.00,
|
|
881
|
+
warehouse: 'CA-01'
|
|
882
|
+
}
|
|
883
|
+
}, true);
|
|
884
|
+
|
|
885
|
+
// 5. Admin updates case with history
|
|
886
|
+
await cases.appendHistory(collectionId, appId, claim.id, {
|
|
887
|
+
entry: {
|
|
888
|
+
action: 'replacement_shipped',
|
|
889
|
+
tracking: 'UPS-123456789'
|
|
890
|
+
},
|
|
891
|
+
historyTarget: 'owner',
|
|
892
|
+
status: 'in_progress'
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
// 6. Customer receives item, admin closes case
|
|
896
|
+
await cases.update(collectionId, appId, claim.id, {
|
|
897
|
+
status: 'resolved',
|
|
898
|
+
admin: { resolvedBy: 'admin_sarah', satisfactionScore: 5 }
|
|
899
|
+
}, true);
|
|
900
|
+
|
|
901
|
+
// 7. Generate analytics
|
|
902
|
+
const monthlyReport = await cases.summary(collectionId, appId, {
|
|
903
|
+
period: { from: '2026-02-01', to: '2026-02-28' }
|
|
904
|
+
});
|
|
905
|
+
```
|
|
906
|
+
|
|
907
|
+
---
|
|
908
|
+
|
|
909
|
+
## TypeScript Usage
|
|
910
|
+
|
|
911
|
+
Import types and functions:
|
|
912
|
+
|
|
913
|
+
```typescript
|
|
914
|
+
import {
|
|
915
|
+
cases, threads, records,
|
|
916
|
+
AppCase, AppThread, AppRecord,
|
|
917
|
+
CreateCaseInput, CreateThreadInput, CreateRecordInput,
|
|
918
|
+
PaginatedResponse, AggregateResponse
|
|
919
|
+
} from '@proveanything/smartlinks';
|
|
920
|
+
|
|
921
|
+
// Fully typed
|
|
922
|
+
const newCase: AppCase = await cases.create(collectionId, appId, {
|
|
923
|
+
category: 'support',
|
|
924
|
+
data: { issue: 'Login problem' }
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
const threadList: PaginatedResponse<AppThread> = await threads.list(
|
|
928
|
+
collectionId,
|
|
929
|
+
appId,
|
|
930
|
+
{ limit: 50, sort: 'createdAt:desc' }
|
|
931
|
+
);
|
|
932
|
+
```
|
|
933
|
+
|
|
934
|
+
---
|
|
935
|
+
|
|
936
|
+
## API Reference
|
|
937
|
+
|
|
938
|
+
For complete endpoint documentation, query parameters, and response schemas, see:
|
|
939
|
+
|
|
940
|
+
- [API_SUMMARY.md](./API_SUMMARY.md) — Full REST API reference
|
|
941
|
+
- [TypeScript source](../src/types/appObjects.ts) — Type definitions
|
|
942
|
+
- [API wrappers](../src/api/appObjects.ts) — Implementation
|
|
943
|
+
|
|
944
|
+
---
|
|
945
|
+
|
|
946
|
+
## Questions?
|
|
947
|
+
|
|
948
|
+
These three object types are incredibly flexible building blocks. If you're unsure which to use for your use case, ask yourself:
|
|
949
|
+
|
|
950
|
+
- Does it need tracking to closure? → **Case**
|
|
951
|
+
- Is it a conversation or discussion? → **Thread**
|
|
952
|
+
- Is it data with a lifecycle or hierarchy? → **Record**
|
|
953
|
+
|
|
954
|
+
When in doubt, start with **Records** — they're the most generic and can be shaped to fit almost anything.
|