@boldvideo/bold-js 1.15.2 → 1.16.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,380 @@
1
+ ---
2
+ title: "feat: Add Viewers & Progress Tracking API to SDK"
3
+ type: feat
4
+ date: 2026-01-23
5
+ ---
6
+
7
+ # Add Viewers & Progress Tracking API to SDK
8
+
9
+ ## Overview
10
+
11
+ Implement the Viewers API in the bold-js SDK to enable course platforms to sync external users and track their video watch progress. Adds a new `bold.viewers` namespace with CRUD operations and flat progress methods.
12
+
13
+ ## Problem Statement / Motivation
14
+
15
+ Course platforms integrating Bold Video need to:
16
+ 1. Sync their user database with Bold's viewer system
17
+ 2. Track which videos users have watched and their progress
18
+ 3. Mark videos as complete for course completion tracking
19
+ 4. Query progress filtered by collection (course)
20
+
21
+ The backend API exists (`/api/v1/viewers/*`), but the SDK doesn't expose it yet.
22
+
23
+ ## Proposed Solution
24
+
25
+ Add a new `viewers` module following the existing `fetchers.ts` pattern:
26
+ - Curried functions that take axios client
27
+ - Flat method naming (no nested namespaces)
28
+ - Use existing `Response<T>` wrapper type
29
+ - Minimal new types - only what's necessary
30
+
31
+ ### API Surface
32
+
33
+ ```typescript
34
+ const bold = createClient(apiKey);
35
+
36
+ // Viewer CRUD
37
+ bold.viewers.list()
38
+ bold.viewers.get(id)
39
+ bold.viewers.lookup({ externalId }) // or { email }
40
+ bold.viewers.create({ name, email?, externalId?, traits? })
41
+ bold.viewers.update(id, { name?, email?, externalId?, traits? })
42
+
43
+ // Progress tracking (flat methods on viewers namespace)
44
+ bold.viewers.listProgress(viewerId, options?)
45
+ bold.viewers.getProgress(viewerId, videoId)
46
+ bold.viewers.saveProgress(viewerId, videoId, { currentTime, duration })
47
+ ```
48
+
49
+ ### Deferred Features (YAGNI)
50
+
51
+ These can be added when requested:
52
+ - `viewers.delete(id)` - rare admin action
53
+ - `viewers.deleteProgress(viewerId, videoId)` - rare admin action
54
+ - `viewers.completeProgress(viewerId, videoId)` - use `saveProgress` with full duration
55
+ - Progress list pagination
56
+
57
+ ### Implemented (originally deferred)
58
+ - `viewers.lookup({ externalId } | { email })` - **Added**: essential for course platforms syncing users
59
+
60
+ ## Technical Approach
61
+
62
+ ### Architecture
63
+
64
+ Follow the established `fetchers.ts` pattern (not the `ai.ts` factory pattern):
65
+
66
+ ```
67
+ /src
68
+ ├── lib/
69
+ │ ├── viewers.ts # NEW: viewer fetcher functions
70
+ │ ├── types.ts # ADD: Viewer, ViewerProgress types
71
+ │ └── client.ts # MODIFY: add viewers namespace
72
+ └── index.ts # MODIFY: export new types
73
+ ```
74
+
75
+ ### Implementation
76
+
77
+ **Tasks:**
78
+ - [x] Add viewer types to `/src/lib/types.ts`
79
+ - [x] Create `/src/lib/viewers.ts` with fetcher functions
80
+ - [x] Wire up viewers in `/src/lib/client.ts`
81
+ - [x] Export types from `/src/index.ts`
82
+
83
+ **File: `/src/lib/types.ts` (additions ~30 lines)**
84
+
85
+ ```typescript
86
+ // ============================================
87
+ // Viewers API Types
88
+ // ============================================
89
+
90
+ /**
91
+ * Viewer represents an external user from a course platform
92
+ */
93
+ export type Viewer = {
94
+ id: string;
95
+ name: string;
96
+ email?: string;
97
+ externalId?: string;
98
+ /** Key-value metadata. Keys must start with letter/underscore, contain only alphanumeric/underscore */
99
+ traits?: Record<string, unknown>;
100
+ insertedAt: string;
101
+ updatedAt: string;
102
+ };
103
+
104
+ /**
105
+ * Progress record for a viewer-video pair
106
+ */
107
+ export type ViewerProgress = {
108
+ id: string;
109
+ viewerId: string;
110
+ videoId: string;
111
+ /** Current playback position in seconds */
112
+ currentTime: number;
113
+ /** Total video duration in seconds */
114
+ duration: number;
115
+ /** Calculated: (currentTime / duration) * 100 */
116
+ percentage: number;
117
+ completed: boolean;
118
+ completedAt?: string;
119
+ insertedAt: string;
120
+ updatedAt: string;
121
+ };
122
+
123
+ /**
124
+ * Options for listing viewer progress
125
+ */
126
+ export type ListProgressOptions = {
127
+ /** Filter by completion status */
128
+ completed?: boolean;
129
+ /** Filter to videos in a specific collection */
130
+ collectionId?: string;
131
+ };
132
+ ```
133
+
134
+ **File: `/src/lib/viewers.ts` (~90 lines)**
135
+
136
+ ```typescript
137
+ import { AxiosInstance } from "axios";
138
+ import { camelizeKeys } from "../util/camelize";
139
+ import type { Viewer, ViewerProgress, ListProgressOptions } from "./types";
140
+
141
+ type Response<T> = { data: T };
142
+ type ApiClient = AxiosInstance;
143
+
144
+ // Re-use the get helper pattern from fetchers.ts
145
+ async function get<T>(client: ApiClient, url: string): Promise<T> {
146
+ try {
147
+ const res = await client.get(url);
148
+ return camelizeKeys(res.data) as T;
149
+ } catch (error) {
150
+ console.error(`Error fetching from ${url}`, error);
151
+ throw error;
152
+ }
153
+ }
154
+
155
+ async function post<T>(client: ApiClient, url: string, data?: Record<string, unknown>): Promise<T> {
156
+ try {
157
+ const res = await client.post(url, data);
158
+ return camelizeKeys(res.data) as T;
159
+ } catch (error) {
160
+ console.error(`Error posting to ${url}`, error);
161
+ throw error;
162
+ }
163
+ }
164
+
165
+ async function patch<T>(client: ApiClient, url: string, data: Record<string, unknown>): Promise<T> {
166
+ try {
167
+ const res = await client.patch(url, data);
168
+ return camelizeKeys(res.data) as T;
169
+ } catch (error) {
170
+ console.error(`Error patching ${url}`, error);
171
+ throw error;
172
+ }
173
+ }
174
+
175
+ // --- Viewer CRUD ---
176
+
177
+ export function fetchViewers(client: ApiClient) {
178
+ return async () => {
179
+ return get<{ viewers: Viewer[] }>(client, 'viewers');
180
+ };
181
+ }
182
+
183
+ export function fetchViewer(client: ApiClient) {
184
+ return async (id: string) => {
185
+ if (!id) throw new Error('Viewer ID is required');
186
+ return get<{ viewer: Viewer }>(client, `viewers/${id}`);
187
+ };
188
+ }
189
+
190
+ export function createViewer(client: ApiClient) {
191
+ return async (data: { name: string; email?: string; externalId?: string; traits?: Record<string, unknown> }) => {
192
+ if (!data.name) throw new Error('Viewer name is required');
193
+ return post<{ viewer: Viewer }>(client, 'viewers', {
194
+ viewer: {
195
+ name: data.name,
196
+ email: data.email,
197
+ external_id: data.externalId,
198
+ traits: data.traits,
199
+ }
200
+ });
201
+ };
202
+ }
203
+
204
+ export function updateViewer(client: ApiClient) {
205
+ return async (id: string, data: { name?: string; email?: string; externalId?: string; traits?: Record<string, unknown> }) => {
206
+ if (!id) throw new Error('Viewer ID is required');
207
+ const body: Record<string, unknown> = {};
208
+ if (data.name !== undefined) body.name = data.name;
209
+ if (data.email !== undefined) body.email = data.email;
210
+ if (data.externalId !== undefined) body.external_id = data.externalId;
211
+ if (data.traits !== undefined) body.traits = data.traits;
212
+ return patch<{ viewer: Viewer }>(client, `viewers/${id}`, { viewer: body });
213
+ };
214
+ }
215
+
216
+ // --- Progress Tracking (flat methods) ---
217
+
218
+ export function fetchViewerProgress(client: ApiClient) {
219
+ return async (viewerId: string, options?: ListProgressOptions) => {
220
+ if (!viewerId) throw new Error('Viewer ID is required');
221
+ const params = new URLSearchParams();
222
+ if (options?.completed !== undefined) params.set('completed', String(options.completed));
223
+ if (options?.collectionId) params.set('collection_id', options.collectionId);
224
+ const query = params.toString();
225
+ const url = query ? `viewers/${viewerId}/progress?${query}` : `viewers/${viewerId}/progress`;
226
+ return get<{ progress: ViewerProgress[]; meta: { total: number; completed: number; inProgress: number } }>(client, url);
227
+ };
228
+ }
229
+
230
+ export function fetchProgress(client: ApiClient) {
231
+ return async (viewerId: string, videoId: string) => {
232
+ if (!viewerId) throw new Error('Viewer ID is required');
233
+ if (!videoId) throw new Error('Video ID is required');
234
+ return get<{ progress: ViewerProgress }>(client, `viewers/${viewerId}/progress/${videoId}`);
235
+ };
236
+ }
237
+
238
+ export function saveProgress(client: ApiClient) {
239
+ return async (viewerId: string, videoId: string, data: { currentTime: number; duration: number }) => {
240
+ if (!viewerId) throw new Error('Viewer ID is required');
241
+ if (!videoId) throw new Error('Video ID is required');
242
+ if (data.currentTime === undefined) throw new Error('currentTime is required');
243
+ if (data.duration === undefined) throw new Error('duration is required');
244
+ return post<{ progress: ViewerProgress }>(client, `viewers/${viewerId}/progress/${videoId}`, {
245
+ progress: {
246
+ current_time: data.currentTime,
247
+ duration: data.duration,
248
+ }
249
+ });
250
+ };
251
+ }
252
+ ```
253
+
254
+ **File: `/src/lib/client.ts` (changes)**
255
+
256
+ ```typescript
257
+ import {
258
+ fetchViewers,
259
+ fetchViewer,
260
+ createViewer,
261
+ updateViewer,
262
+ fetchViewerProgress,
263
+ fetchProgress,
264
+ saveProgress,
265
+ } from './viewers';
266
+
267
+ // In createClient(), add to return object:
268
+ return {
269
+ settings: fetchSettings(apiClient),
270
+ videos: { ... },
271
+ playlists: { ... },
272
+ viewers: {
273
+ list: fetchViewers(apiClient),
274
+ get: fetchViewer(apiClient),
275
+ create: createViewer(apiClient),
276
+ update: updateViewer(apiClient),
277
+ listProgress: fetchViewerProgress(apiClient),
278
+ getProgress: fetchProgress(apiClient),
279
+ saveProgress: saveProgress(apiClient),
280
+ },
281
+ ai: createAI(aiConfig),
282
+ trackEvent: ...,
283
+ trackPageView: ...,
284
+ };
285
+ ```
286
+
287
+ **File: `/src/index.ts` (additions)**
288
+
289
+ ```typescript
290
+ export type {
291
+ // ... existing exports ...
292
+
293
+ // Viewers API
294
+ Viewer,
295
+ ViewerProgress,
296
+ ListProgressOptions,
297
+ } from "./lib/types";
298
+ ```
299
+
300
+ ## Acceptance Criteria
301
+
302
+ ### Functional Requirements
303
+
304
+ - [ ] `bold.viewers.list()` returns all viewers
305
+ - [ ] `bold.viewers.get(id)` returns a single viewer by UUID
306
+ - [ ] `bold.viewers.create(data)` creates a new viewer with name (required) and optional fields
307
+ - [ ] `bold.viewers.update(id, data)` updates viewer fields (traits replaced, not merged)
308
+ - [ ] `bold.viewers.listProgress(viewerId)` returns all progress for a viewer
309
+ - [ ] `bold.viewers.listProgress(viewerId, { completed: true })` filters by completion
310
+ - [ ] `bold.viewers.listProgress(viewerId, { collectionId })` filters by collection
311
+ - [ ] `bold.viewers.getProgress(viewerId, videoId)` returns progress for a video
312
+ - [ ] `bold.viewers.saveProgress(viewerId, videoId, data)` creates or updates progress
313
+
314
+ ### Non-Functional Requirements
315
+
316
+ - [ ] All responses are camelCased (consistent with existing SDK)
317
+ - [ ] Request bodies use snake_case for API compatibility (manual transformation)
318
+ - [ ] Trait keys are preserved as-is (not transformed)
319
+ - [ ] TypeScript types are accurate and exported
320
+ - [ ] Errors include descriptive messages with context
321
+ - [ ] No new runtime dependencies added
322
+ - [ ] Follows existing `fetchers.ts` pattern
323
+
324
+ ### Quality Gates
325
+
326
+ - [ ] `pnpm run lint` passes
327
+ - [ ] `pnpm run build` succeeds
328
+ - [ ] Types are correctly exported and usable by consumers
329
+
330
+ ## Example Usage
331
+
332
+ ```typescript
333
+ import { createClient } from '@boldvideo/bold-js';
334
+
335
+ const bold = createClient('your-api-key');
336
+
337
+ // 1. Create viewer when user signs up
338
+ const { viewer } = await bold.viewers.create({
339
+ name: 'John Doe',
340
+ externalId: 'user_123',
341
+ email: 'john@example.com',
342
+ traits: { plan: 'pro', company: 'Acme Inc' }
343
+ });
344
+
345
+ // 2. Track progress as video plays (call every 5-10 seconds)
346
+ await bold.viewers.saveProgress(viewer.id, 'video-id', {
347
+ currentTime: 120,
348
+ duration: 600
349
+ });
350
+
351
+ // 3. Mark video complete by setting currentTime = duration
352
+ await bold.viewers.saveProgress(viewer.id, 'video-id', {
353
+ currentTime: 600,
354
+ duration: 600
355
+ });
356
+
357
+ // 4. Get course progress
358
+ const { progress, meta } = await bold.viewers.listProgress(viewer.id, {
359
+ collectionId: 'course-collection-id'
360
+ });
361
+ console.log(`Completed ${meta.completed} of ${meta.total} videos`);
362
+ ```
363
+
364
+ ## Summary of Changes from Original Plan
365
+
366
+ | Original | Revised | Reason |
367
+ |----------|---------|--------|
368
+ | `viewers.progress.list()` nested | `viewers.listProgress()` flat | Reviewer consensus: flat is better |
369
+ | `ViewerResponse`, `ViewersResponse` types | Inline `{ viewer: Viewer }` | Match existing `Response<T>` pattern |
370
+ | `snakeize.ts` utility file | Manual snake_case in request bodies | Simpler, avoid new abstraction |
371
+ | 11 methods | 7 methods | YAGNI: defer delete, lookup, completeProgress |
372
+ | ~290 lines | ~120 lines | 60% reduction per simplicity review |
373
+ | Factory pattern (`createViewers`) | Curried functions | Match `fetchers.ts` pattern |
374
+
375
+ ## References
376
+
377
+ - Client factory: `/src/lib/client.ts`
378
+ - Existing fetchers: `/src/lib/fetchers.ts`
379
+ - Type definitions: `/src/lib/types.ts`
380
+ - API Documentation: `notes/bold/viewers-api.md`
package/llms.txt CHANGED
@@ -61,6 +61,33 @@ All content methods return `Promise<{ data: T }>`.
61
61
  - `bold.playlists.list()` - List playlists
62
62
  - `bold.playlists.get(id)` - Get playlist with videos
63
63
 
64
+ ## Viewers API
65
+
66
+ Manage external users and track video progress. Returns `Promise<{ data: T }>` (consistent with other SDK methods).
67
+
68
+ ### Viewer CRUD
69
+
70
+ - `bold.viewers.list()` - List all viewers
71
+ - `bold.viewers.get(id)` - Get viewer by UUID
72
+ - `bold.viewers.lookup({ externalId })` - Find viewer by external ID
73
+ - `bold.viewers.lookup({ email })` - Find viewer by email
74
+ - `bold.viewers.create({ name, email?, externalId?, traits? })` - Create viewer
75
+ - `bold.viewers.update(id, { name?, email?, externalId?, traits? })` - Update viewer (traits replaced, not merged)
76
+
77
+ ### Progress Tracking
78
+
79
+ - `bold.viewers.listProgress(viewerId, { completed?, collectionId? }?)` - List progress records
80
+ - `bold.viewers.getProgress(viewerId, videoId)` - Get progress for specific video
81
+ - `bold.viewers.saveProgress(viewerId, videoId, { currentTime, duration })` - Upsert progress
82
+
83
+ ```typescript
84
+ // Example: Course platform integration
85
+ const { data: viewer } = await bold.viewers.lookup({ externalId: 'user_123' });
86
+ await bold.viewers.saveProgress(viewer.id, 'video-id', { currentTime: 120, duration: 600 });
87
+ const { data: progress, meta } = await bold.viewers.listProgress(viewer.id, { collectionId: 'course-id' });
88
+ console.log(`Completed ${meta.completed} of ${meta.total} videos`);
89
+ ```
90
+
64
91
  ## AI Methods
65
92
 
66
93
  All AI methods return `AsyncIterable<AIEvent>` (streaming) or `Promise<AIResponse>` (non-streaming).
@@ -194,9 +221,40 @@ type AIEvent =
194
221
  }
195
222
  ```
196
223
 
224
+ ### Viewer
225
+
226
+ ```typescript
227
+ {
228
+ id: string;
229
+ name: string;
230
+ email?: string;
231
+ externalId?: string;
232
+ traits?: Record<string, unknown>; // User-defined metadata (keys preserved as-is)
233
+ insertedAt: string;
234
+ updatedAt: string;
235
+ }
236
+ ```
237
+
238
+ ### ViewerProgress
239
+
240
+ ```typescript
241
+ {
242
+ id: string;
243
+ viewerId: string;
244
+ videoId: string;
245
+ currentTime: number; // Playback position in seconds
246
+ duration: number; // Video duration in seconds
247
+ percentage: number; // 0-100
248
+ completed: boolean;
249
+ completedAt?: string;
250
+ insertedAt: string;
251
+ updatedAt: string;
252
+ }
253
+ ```
254
+
197
255
  ### Other types exported
198
256
 
199
- `Playlist`, `Settings`, `Portal`, `MenuItem`, `AIResponse`, `AIUsage`, `AIContextMessage`, `Conversation`, `ConversationMessage`, `RecommendationsResponse`
257
+ `Playlist`, `Settings`, `Portal`, `MenuItem`, `AIResponse`, `AIUsage`, `AIContextMessage`, `Conversation`, `ConversationMessage`, `RecommendationsResponse`, `Viewer`, `ViewerProgress`, `ViewerLookupParams`, `ListProgressOptions`
200
258
 
201
259
  ## Error Handling
202
260
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@boldvideo/bold-js",
3
3
  "license": "MIT",
4
- "version": "1.15.2",
4
+ "version": "1.16.0",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",
7
7
  "types": "dist/index.d.ts",