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.
93 lines
2.8 KiB
JavaScript
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');
|
|
})
|
|
);
|
|
});
|