PageSourceSearch

https://app.clindoc.ai/recording-upload-sw.js

js clindoc.ai collected 2026-10-05 15:30:38 UTC 13,439 bytes, 404 lines download raw bytes

1/**
2 * Service Worker for Background Recording Uploads
3 * Handles background sync for recording uploads to continue even after page close
4 * 
5 * ⚠️  CRITICAL: Schema Synchronization Required ⚠️
6 * 
7 * This service worker cannot import ES modules, so constants and schema are
8 * duplicated here. When making changes, you MUST update:
9 * 
10 * 1. apps/web/lib/indexeddb/constants.ts - Source of truth for constants
11 * 2. apps/web/lib/indexeddb/db-config.ts - TypeScript schema definition
12 * 3. apps/web/public/recording-upload-sw.js - This file (service worker)
13 * 
14 * All three files must use identical:
15 * - Database name
16 * - Database version
17 * - Object store names
18 * - Index names
19 * - Schema structure
20 */
21
22const SW_VERSION = '1.2.0';
23const UPLOAD_SYNC_TAG = 'upload-recording';
24
25// Database configuration (must match constants.ts)
26const DB_NAME_BASE = 'ClindocBackupDB';
27const DB_VERSION = 3;
28
29/**
30 * Build the staff-scoped database name (mirrors constants.ts buildDBName).
31 * staffId === 0 or falsy falls back to the legacy unscoped name.
32 */
33function buildDBName(staffId) {
34  return staffId ? `${DB_NAME_BASE}-staff-${staffId}` : DB_NAME_BASE;
35}
36
37/**
38 * Parse a sync tag into { staffId, appointmentId }.
39 * New format: upload-recording-{staffId}-{appointmentId}
40 * Legacy format: upload-recording-{appointmentId}
41 */
42function parseSyncTag(tag) {
43  const suffix = tag.replace('upload-recording-', '');
44  const parts = suffix.split('-');
45  if (parts.length >= 2) {
46    // New format: staffId-appointmentId
47    return { staffId: parseInt(parts[0], 10), appointmentId: parseInt(parts[1], 10) };
48  }
49  // Legacy format: only appointmentId
50  return { staffId: null, appointmentId: parseInt(parts[0], 10) };
51}
52
53// Object store names (must match constants.ts)
54const STORE_CHUNKS = 'chunks';
55const STORE_SESSIONS = 'sessions';
56const STORE_UPLOAD_METADATA = 'upload-metadata';
57const STORE_UPLOAD_RESULTS = 'upload-results';
58
59// Index names (must match constants.ts)
60const INDEX_BY_APPOINTMENT = 'by-appointment';
61const INDEX_BY_CREATED = 'by-created';
62
63// Install and activate
64self.addEventListener('install', (event) => {
65  console.log('[SW] Installing version', SW_VERSION);
66  self.skipWaiting();
67});
68
69self.addEventListener('activate', (event) => {
70  console.log('[SW] Activating version', SW_VERSION);
71  event.waitUntil(clients.claim());
72});
73
74// Handle background sync
75self.addEventListener('sync', (event) => {
76  console.log('[SW] Sync event:', event.tag);
77  
78  if (event.tag.startsWith(UPLOAD_SYNC_TAG)) {
79    event.waitUntil(handleBackgroundUpload(event.tag));
80  }
81});
82
83/**
84 * Main background upload handler
85 */
86async function handleBackgroundUpload(tag) {
87  const { staffId, appointmentId } = parseSyncTag(tag);
88  const dbName = buildDBName(staffId);
89  
90  console.log('[SW] Starting background upload for appointment:', appointmentId, 'staff:', staffId, 'db:', dbName);
91  
92  try {
93    // Open IndexedDB (staff-scoped)
94    const db = await openDB(dbName);
95
96    // Idempotency check: if a successful result was already written on a prior
97    // run (e.g. createMedia succeeded but clearAppointmentData failed causing a
98    // retry), skip S3 + API and just clean up remaining local data.
99    const existingResult = await getUploadResult(db, appointmentId);
100    if (existingResult && existingResult.success) {
101      console.log('[SW] Upload already completed for appointment:', appointmentId, 'mediaId:', existingResult.mediaId, '— skipping duplicate upload');
102      await clearAppointmentData(db, appointmentId).catch((err) => {
103        console.warn('[SW] clearAppointmentData failed on idempotency cleanup (non-fatal):', err);
104      });
105      await notifyClients({
106        type: 'UPLOAD_COMPLETE',
107        appointmentId,
108        success: true,
109        mediaId: existingResult.mediaId,
110      });
111      return;
112    }
113    
114    // Get upload metadata
115    const uploadMeta = await getUploadMetadata(db, appointmentId);
116    
117    if (!uploadMeta) {
118      console.error('[SW] No upload metadata found for appointment:', appointmentId);
119      throw new Error('No upload metadata found');
120    }
121
122    // Get recording chunks
123    const chunks = await getChunks(db, appointmentId);
124    
125    if (!chunks || chunks.length === 0) {
126      console.error('[SW] No chunks found for appointment:', appointmentId);
127      throw new Error('No recording data found');
128    }
129
130    console.log('[SW] Found', chunks.length, 'chunks, total size:', 
131      chunks.reduce((sum, c) => sum + c.data.size, 0));
132
133    // Combine chunks into single blob
134    const blob = new Blob(chunks.map(c => c.data), { 
135      type: uploadMeta.mimeType || 'audio/webm'
136    });
137
138    console.log('[SW] Combined blob size:', blob.size);
139
140    // Step 1: Upload to S3 using presigned URL
141    console.log('[SW] Uploading to S3...');
142    const s3Response = await fetch(uploadMeta.uploadUrl, {
143      method: 'PUT',
144      body: blob,
145      headers: {
146        'Content-Type': blob.type,
147      },
148    });
149
150    if (!s3Response.ok) {
151      throw new Error(`S3 upload failed: ${s3Response.status} ${s3Response.statusText}`);
152    }
153
154    console.log('[SW] S3 upload successful');
155
156    // Step 2: Create media record via API
157    console.log('[SW] Creating media record...');
158    const apiResponse = await fetch(uploadMeta.apiUrl, {
159      method: 'POST',
160      headers: {
161        'Content-Type': 'application/json',
162        'Authorization': `Bearer ${uploadMeta.authToken}`,
163      },
164      body: JSON.stringify(uploadMeta.mediaData),
165    });
166
167    if (!apiResponse.ok) {
168      const errorText = await apiResponse.text();
169      throw new Error(`API call failed: ${apiResponse.status} - ${errorText}`);
170    }
171
172    const mediaResult = await apiResponse.json();
173    console.log('[SW] Media record created:', mediaResult.id);
174
175    // Step 3: Write durable result record BEFORE clearing local data.
176    // This ensures the idempotency check above finds a success marker if the
177    // SW is killed or clearAppointmentData fails, preventing a duplicate upload
178    // on the next Background Sync retry.
179    await writeUploadResult(db, {
180      appointmentId,
181      success: true,
182      mediaId: mediaResult.id,
183      completedAt: Date.now(),
184      staffId: staffId || null,
185    });
186
187    // Step 4: Clear IndexedDB (safe to fail — result is already recorded above)
188    await clearAppointmentData(db, appointmentId);
189    console.log('[SW] Cleared IndexedDB data');
190
191    // Step 5: Best-effort notify open clients for immediate UI update
192    await notifyClients({
193      type: 'UPLOAD_COMPLETE',
194      appointmentId,
195      success: true,
196      mediaId: mediaResult.id,
197    });
198
199    console.log('[SW] Background upload completed successfully');
200
201  } catch (error) {
202    console.error('[SW] Background upload failed:', error);
203
204    // Write durable failure record so main thread can track it
205    try {
206      const db = await openDB(dbName);
207      await writeUploadResult(db, {
208        appointmentId,
209        success: false,
210        error: error.message,
211        completedAt: Date.now(),
212        staffId: staffId || null,
213      });
214    } catch (writeErr) {
215      console.error('[SW] Failed to write upload result:', writeErr);
216    }
217    
218    // Best-effort notify open clients
219    await notifyClients({
220      type: 'UPLOAD_COMPLETE',
221      appointmentId,
222      success: false,
223      error: error.message,
224    });
225    
226    // Re-throw to trigger retry by browser
227    throw error;
228  }
229}
230
231/**
232 * Open IndexedDB
233 * Schema must match apps/web/lib/indexeddb/db-config.ts
234 * @param {string} [dbName] - Database name (defaults to legacy unscoped name)
235 */
236function openDB(dbName) {
237  const name = dbName || DB_NAME_BASE;
238  return new Promise((resolve, reject) => {
239    const request = indexedDB.open(name, DB_VERSION);
240    
241    request.onupgradeneeded = (event) => {
242      const db = event.target.result;
243      const oldVersion = event.oldVersion;
244      
245      console.log('[SW-DB] Upgrading from version', oldVersion, 'to', DB_VERSION);
246      
247      // Create chunks store (replaces old 'recordings' store)
248      if (!db.objectStoreNames.contains(STORE_CHUNKS)) {
249        const chunkStore = db.createObjectStore(STORE_CHUNKS, { 
250          keyPath: 'id', 
251          autoIncrement: true 
252        });
253        chunkStore.createIndex(INDEX_BY_APPOINTMENT, 'appointmentId', { unique: false });
254        chunkStore.createIndex(INDEX_BY_CREATED, 'createdAt', { unique: false });
255        console.log('[SW-DB] Created chunks store with indexes');
256      }
257      
258      // Migrate from old 'recordings' store if it exists
259      if (oldVersion < 2 && db.objectStoreNames.contains('recordings')) {
260        console.log('[SW-DB] Deleting old recordings store');
261        db.deleteObjectStore('recordings');
262      }
263      
264      // Create sessions store
265      if (!db.objectStoreNames.contains(STORE_SESSIONS)) {
266        db.createObjectStore(STORE_SESSIONS, { keyPath: 'appointmentId' });
267        console.log('[SW-DB] Created sessions store');
268      }
269      
270      // Create upload-metadata store
271      if (!db.objectStoreNames.contains(STORE_UPLOAD_METADATA)) {
272        db.createObjectStore(STORE_UPLOAD_METADATA, { keyPath: 'appointmentId' });
273        console.log('[SW-DB] Created upload-metadata store');
274      }
275
276      // v3: durable upload outcome records
277      if (!db.objectStoreNames.contains(STORE_UPLOAD_RESULTS)) {
278        db.createObjectStore(STORE_UPLOAD_RESULTS, { keyPath: 'appointmentId' });
279        console.log('[SW-DB] Created upload-results store');
280      }
281    };
282    
283    request.onsuccess = () => resolve(request.result);
284    request.onerror = () => reject(request.error);
285  });
286}
287
288/**
289 * Get upload metadata from IndexedDB
290 */
291function getUploadMetadata(db, appointmentId) {
292  return new Promise((resolve, reject) => {
293    const tx = db.transaction([STORE_UPLOAD_METADATA], 'readonly');
294    const store = tx.objectStore(STORE_UPLOAD_METADATA);
295    const request = store.get(appointmentId);
296    request.onsuccess = () => resolve(request.result);
297    request.onerror = () => reject(request.error);
298  });
299}
300
301/**
302 * Get all chunks for an appointment
303 * Uses INDEX_BY_APPOINTMENT to match db-config.ts
304 */
305function getChunks(db, appointmentId) {
306  return new Promise((resolve, reject) => {
307    const tx = db.transaction([STORE_CHUNKS], 'readonly');
308    const store = tx.objectStore(STORE_CHUNKS);
309    const index = store.index(INDEX_BY_APPOINTMENT);
310    const request = index.getAll(appointmentId);
311    request.onsuccess = () => resolve(request.result);
312    request.onerror = () => reject(request.error);
313  });
314}
315
316/**
317 * Clear all data for an appointment
318 */
319async function clearAppointmentData(db, appointmentId) {
320  const tx = db.transaction([STORE_SESSIONS, STORE_CHUNKS, STORE_UPLOAD_METADATA], 'readwrite');
321  
322  // Delete session
323  await new Promise((resolve, reject) => {
324    const request = tx.objectStore(STORE_SESSIONS).delete(appointmentId);
325    request.onsuccess = () => resolve();
326    request.onerror = () => reject(request.error);
327  });
328  
329  // Delete all chunks using INDEX_BY_APPOINTMENT
330  const chunkStore = tx.objectStore(STORE_CHUNKS);
331  const index = chunkStore.index(INDEX_BY_APPOINTMENT);
332  const chunks = await new Promise((resolve, reject) => {
333    const request = index.getAllKeys(appointmentId);
334    request.onsuccess = () => resolve(request.result);
335    request.onerror = () => reject(request.error);
336  });
337  
338  for (const chunkId of chunks) {
339    await new Promise((resolve, reject) => {
340      const request = chunkStore.delete(chunkId);
341      request.onsuccess = () => resolve();
342      request.onerror = () => reject(request.error);
343    });
344  }
345  
346  // Delete upload metadata
347  await new Promise((resolve, reject) => {
348    const request = tx.objectStore(STORE_UPLOAD_METADATA).delete(appointmentId);
349    request.onsuccess = () => resolve();
350    request.onerror = () => reject(request.error);
351  });
352}
353
354/**
355 * Read an existing upload result for an appointment (used for idempotency).
356 * Returns undefined if no result exists.
357 */
358function getUploadResult(db, appointmentId) {
359  return new Promise((resolve, reject) => {
360    const tx = db.transaction([STORE_UPLOAD_RESULTS], 'readonly');
361    const store = tx.objectStore(STORE_UPLOAD_RESULTS);
362    const request = store.get(appointmentId);
363    request.onsuccess = () => resolve(request.result);
364    request.onerror = () => reject(request.error);
365  });
366}
367
368/**
369 * Write a durable upload result record to IndexedDB.
370 * The main thread reads these on next load / visibility change to fire PostHog events.
371 */
372function writeUploadResult(db, result) {
373  return new Promise((resolve, reject) => {
374    const tx = db.transaction([STORE_UPLOAD_RESULTS], 'readwrite');
375    const store = tx.objectStore(STORE_UPLOAD_RESULTS);
376    const request = store.put(result);
377    request.onsuccess = () => resolve();
378    request.onerror = () => reject(request.error);
379  });
380}
381
382/**
383 * Notify all clients (browser tabs) of upload result
384 */
385async function notifyClients(message) {
386  const clients = await self.clients.matchAll({ includeUncontrolled: true });
387  console.log('[SW] Notifying', clients.length, 'clients');
388  
389  clients.forEach(client => {
390    client.postMessage(message);
391  });
392}
393
394// Handle messages from clients
395self.addEventListener('message', (event) => {
396  console.log('[SW] Received message:', event.data);
397  
398  if (event.data.type === 'SKIP_WAITING') {
399    self.skipWaiting();
400  }
401});
402
403console.log('[SW] Service worker loaded, version', SW_VERSION);
404

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.