Files
joshuaandClaude Opus 5.5 f99b84858e Session gaps: per-session waitlist, door session picker, all-sessions choice
- Waitlist per session: entries for pick-sessions tickets name a session;
  offers use that session's free places, never run past its start, and
  claim links tick it. Held offers count towards session capacity.
- At the door: the Select items pop-up has the session picker, with
  today's session preselected for new attendees.
- On pick-sessions events every ticket shows the session list straight
  away. "All sessions" tickets record the ticked sessions
  (RegistrationOption.sessionIds) at one price; scanning, attendance,
  certificates, reminders and capacity follow them.
- Fix: per-session stock limits weren't enforced (takenCount filtered
  WaitlistEntry by a sessionId column that didn't exist, and the error
  was swallowed), and the public event page lost its stock info.
- Fix: editing a door registration with picked sessions couldn't be saved.
- Fix: the offline-scanning service worker served /_next/static cache-first
  forever, so dev browsers that had opened the scanner ran stale code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:38:21 +02:00

76 lines
2.8 KiB
JavaScript

/*
* Service worker for offline ticket scanning.
*
* Only the scanner page and the static files it needs are cached, both network-first
* with the cached copy as the offline fallback:
* - /_next/static/*. Not cache-first: under `next dev` these files keep their names when
* their content changes, so cache-first served stale code on every page, for good.
* (Production files are hashed and come from the browser's HTTP cache anyway.)
* - The scanner page itself.
* - The page can ask (postMessage { type: 'cache-urls', urls }) for the files it has
* loaded to be cached up front, so it works offline straight after "Enable offline mode".
* API calls are never cached — offline scans go through IndexedDB instead.
*/
// v2: drops v1 caches, which could hold stale development scripts (see above).
const CACHE = 'hope-scanner-v2';
const SCANNER_PATH = '/dashboard/staff/ticket-scanning';
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
const keys = await caches.keys();
await Promise.all(keys.filter(k => k.startsWith('hope-scanner-') && k !== CACHE).map(k => caches.delete(k)));
await self.clients.claim();
})());
});
self.addEventListener('message', (event) => {
const data = event.data || {};
if (data.type !== 'cache-urls' || !Array.isArray(data.urls)) return;
event.waitUntil((async () => {
const cache = await caches.open(CACHE);
const sameOrigin = data.urls.filter(u => {
try { return new URL(u, self.location.origin).origin === self.location.origin; } catch { return false; }
});
await Promise.all(sameOrigin.map(u => cache.add(u).catch(() => {})));
})());
});
self.addEventListener('fetch', (event) => {
const req = event.request;
if (req.method !== 'GET') return;
const url = new URL(req.url);
if (url.origin !== self.location.origin) return;
if (url.pathname.startsWith('/_next/static/')) {
event.respondWith((async () => {
const cache = await caches.open(CACHE);
try {
const res = await fetch(req);
if (res.ok) cache.put(req, res.clone());
return res;
} catch {
const hit = await cache.match(req);
if (hit) return hit;
throw new Error('offline and not cached');
}
})());
return;
}
if (url.pathname === SCANNER_PATH) {
event.respondWith((async () => {
const cache = await caches.open(CACHE);
try {
const res = await fetch(req);
if (res.ok) cache.put(SCANNER_PATH, res.clone());
return res;
} catch {
const hit = (await cache.match(req, { ignoreSearch: true })) || (await cache.match(SCANNER_PATH));
if (hit) return hit;
throw new Error('offline and not cached');
}
})());
}
});