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.