pediatric-ai-scribe-v3/public/sw.js
Daniel ab3ce11539 feat(notes): trash + restore + DOMPurify sanitizer + 9 contract tests
Soft-delete for notes — Daniel asked for "deleted notes go to trash"
so a slip of the finger doesn't lose work.

Schema: migrations/1777090000000_notes-trash.js adds a deleted_at
timestamptz column to personal_notes (NULL = active) plus an index
on (user_id, deleted_at).

Server (src/routes/notes.js):
  GET    /api/notes              now filters deleted_at IS NULL
  GET    /api/notes/trash        new — list trashed items, newest-
                                  deleted first
  DELETE /api/notes/:id          now soft-deletes (sets deleted_at)
  DELETE /api/notes/:id?hard=1   hard-delete, only allowed on items
                                  already in trash (UI bug can't
                                  erase an active note)
  POST   /api/notes/:id/restore  pull a note out of trash
  POST   /api/notes/trash/empty  hard-delete every trashed note for
                                  the user

Frontend (public/components/notes.html + public/js/notes.js +
public/css/styles.css):
  • Sidebar gets two tabs — "Notes" / "Trash (n)" with live count
  • Trash tab shows deleted-at timestamps, Restore + delete-forever
    per row, Empty-trash button at the bottom
  • Active list and trash count refresh in parallel after every
    save / delete / restore
  • Delete button in the editor now says "Move to trash" and uses
    the showConfirm helper (no native dialogs)

Sanitizer swap (public/js/notes.js):
  Replaced the homegrown allowlist walker with DOMPurify (already
  loaded from cdnjs in index.html, used by learningHub.js too).
  Custom HTML sanitizers historically have bypasses; DOMPurify is
  the right primitive.

Tests (test/notes-sanitize.test.js — node:test + jsdom + dompurify
       as new dev deps):
  9 contract tests covering script-tag stripping, inline event
  handlers, img onerror, iframe/object, style attributes, every
  preserved tag in the allowlist, javascript: URI rejection,
  null/undefined input, and nested-script-inside-paragraph. Total
  test count: 37 → 46 passing.

SW cache bumped to pedscribe-v12-notes5.
2026-04-25 01:02:49 +02:00

93 lines
2.8 KiB
JavaScript

// ============================================================
// SERVICE WORKER — Cache shell, network-first for API
// Provides offline fallback for app shell while keeping
// API calls always fresh (critical for medical data accuracy)
// ============================================================
var CACHE_NAME = 'pedscribe-v12-notes5';
var SHELL_ASSETS = [
'/',
'/index.html',
'/css/styles.css',
'/js/app.js',
'/js/auth.js',
'/manifest.json'
];
// Install: precache app shell
self.addEventListener('install', function(event) {
event.waitUntil(
caches.open(CACHE_NAME).then(function(cache) {
return cache.addAll(SHELL_ASSETS);
}).then(function() {
return self.skipWaiting();
})
);
});
// Activate: clear old caches
self.addEventListener('activate', function(event) {
event.waitUntil(
caches.keys().then(function(names) {
return Promise.all(
names.filter(function(name) { return name !== CACHE_NAME; })
.map(function(name) { return caches.delete(name); })
);
}).then(function() {
return self.clients.claim();
})
);
});
// Fetch: network-first for API, cache-first for static assets
self.addEventListener('fetch', function(event) {
var url = new URL(event.request.url);
// Only handle same-origin requests
if (url.origin !== self.location.origin) return;
// API calls — always network, never cache (medical data must be fresh)
if (url.pathname.startsWith('/api/')) {
event.respondWith(fetch(event.request));
return;
}
// Component HTML — network-first with cache fallback
if (url.pathname.startsWith('/components/')) {
event.respondWith(
fetch(event.request).then(function(response) {
var clone = response.clone();
caches.open(CACHE_NAME).then(function(cache) { cache.put(event.request, clone); });
return response;
}).catch(function() {
return caches.match(event.request);
})
);
return;
}
// Static assets (JS, CSS, icons) — network-first so code updates apply immediately
if (url.pathname.match(/\.(js|css|png|ico|woff2?)$/)) {
event.respondWith(
fetch(event.request).then(function(response) {
var clone = response.clone();
caches.open(CACHE_NAME).then(function(cache) { cache.put(event.request, clone); });
return response;
}).catch(function() {
return caches.match(event.request);
})
);
return;
}
// HTML pages — network-first
event.respondWith(
fetch(event.request).then(function(response) {
var clone = response.clone();
caches.open(CACHE_NAME).then(function(cache) { cache.put(event.request, clone); });
return response;
}).catch(function() {
return caches.match(event.request) || caches.match('/index.html');
})
);
});