4c3880e07f
Moderator role existed in the schema and was already respected by
comment deletion, but every admin page/route treated moderator
identically to a regular user (403/redirect). Wires it up narrowly:
admin/layout.tsx now lets admin+moderator through and filters the
nav by role, while every admin-only page (users, tiers, settings,
webhooks, insights, etc.) explicitly redirects moderators away via a
new requireFullAdminPage() helper -- the nav filter is UX, this is
the actual gate. Moderators land on Reports and Recipes: reports
GET/PATCH now accept requireAdmin({allowModerator: true}), and a new
PATCH /api/v1/admin/recipes/[id] lets admin+moderator unpublish a
public recipe (flip to private) as a takedown action, audit-logged.
Also found and fixed a real bug while auditing the PWA push pipeline
for a "push click-through" gap: public/sw.js had no `push` event
listener at all, so incoming push messages never displayed anything
-- push was silently non-functional end-to-end despite the
subscribe/send plumbing all working. Added the push listener
(showNotification) and a notificationclick listener that focuses an
existing tab or opens one at the payload's url.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
155 lines
5.0 KiB
JavaScript
155 lines
5.0 KiB
JavaScript
const CACHE_NAME = "epicure-v1";
|
|
const SHELL_ASSETS = ["/", "/recipes", "/offline"];
|
|
|
|
self.addEventListener("install", event => {
|
|
event.waitUntil(caches.open(CACHE_NAME).then(cache => cache.addAll(SHELL_ASSETS)));
|
|
self.skipWaiting();
|
|
});
|
|
|
|
self.addEventListener("activate", event => {
|
|
event.waitUntil(
|
|
caches.keys().then(keys =>
|
|
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
|
|
)
|
|
);
|
|
self.clients.claim();
|
|
});
|
|
|
|
self.addEventListener("fetch", event => {
|
|
const { request } = event;
|
|
if (request.method !== "GET") return;
|
|
const url = new URL(request.url);
|
|
// Skip API routes
|
|
if (url.pathname.startsWith("/api/")) return;
|
|
// Cache-first for cook mode pages
|
|
if (url.pathname.includes("/cook")) {
|
|
event.respondWith(
|
|
caches.match(request).then(cached =>
|
|
cached ??
|
|
fetch(request).then(res => {
|
|
const clone = res.clone();
|
|
caches.open(CACHE_NAME).then(c => c.put(request, clone));
|
|
return res;
|
|
})
|
|
)
|
|
);
|
|
return;
|
|
}
|
|
// Network-first for other pages, but keep a copy of every successful
|
|
// response — this is what actually makes "recently visited recipes are
|
|
// available offline" (see /offline) true, and what a "Save for offline"
|
|
// action (save-offline-button.tsx) piggybacks on by re-fetching a page
|
|
// it wants pinned.
|
|
event.respondWith(
|
|
fetch(request)
|
|
.then(res => {
|
|
if (res.ok) {
|
|
const clone = res.clone();
|
|
caches.open(CACHE_NAME).then(c => c.put(request, clone));
|
|
}
|
|
return res;
|
|
})
|
|
.catch(() => caches.match(request).then(cached => cached ?? caches.match("/offline")))
|
|
);
|
|
});
|
|
|
|
// --- Background Sync: replay mutations queued while offline ---
|
|
// Mirrors lib/offline-queue.ts's flushOfflineQueue, duplicated here because
|
|
// this file is registered as a classic (non-module) script and can't import
|
|
// app code. Both read/write the same IndexedDB database — keep the store
|
|
// names and shapes in sync with lib/offline-db.ts if either changes.
|
|
const DB_NAME = "epicure-offline";
|
|
const PENDING_ACTIONS_STORE = "pendingActions";
|
|
const SYNC_TAG = "epicure-sync";
|
|
|
|
function openOfflineDb() {
|
|
return new Promise((resolve, reject) => {
|
|
const req = indexedDB.open(DB_NAME, 1);
|
|
req.onsuccess = () => resolve(req.result);
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
}
|
|
|
|
async function getPendingActions(db) {
|
|
return new Promise((resolve, reject) => {
|
|
const tx = db.transaction(PENDING_ACTIONS_STORE, "readonly");
|
|
const req = tx.objectStore(PENDING_ACTIONS_STORE).getAll();
|
|
req.onsuccess = () => resolve(req.result.sort((a, b) => a.createdAt.localeCompare(b.createdAt)));
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
}
|
|
|
|
function deletePendingAction(db, id) {
|
|
return new Promise((resolve, reject) => {
|
|
const tx = db.transaction(PENDING_ACTIONS_STORE, "readwrite");
|
|
tx.objectStore(PENDING_ACTIONS_STORE).delete(id);
|
|
tx.oncomplete = () => resolve();
|
|
tx.onerror = () => reject(tx.error);
|
|
});
|
|
}
|
|
|
|
async function replayPendingActions() {
|
|
const db = await openOfflineDb();
|
|
const pending = await getPendingActions(db);
|
|
let synced = 0;
|
|
for (const action of pending) {
|
|
let ok = false;
|
|
try {
|
|
const res = await fetch(action.url, {
|
|
method: action.method,
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(action.body),
|
|
});
|
|
ok = res.ok;
|
|
} catch {
|
|
ok = false;
|
|
}
|
|
if (!ok) break;
|
|
await deletePendingAction(db, action.id);
|
|
synced += 1;
|
|
}
|
|
if (synced > 0) {
|
|
const clients = await self.clients.matchAll();
|
|
for (const client of clients) client.postMessage({ type: "epicure:offline-synced", count: synced });
|
|
}
|
|
}
|
|
|
|
self.addEventListener("sync", event => {
|
|
if (event.tag === SYNC_TAG) event.waitUntil(replayPendingActions());
|
|
});
|
|
|
|
// --- Push: display incoming notifications and handle taps ---
|
|
// lib/push.ts sends {title, body, url} as the payload. Without a "push"
|
|
// listener, a push message reaches the browser but never displays anything
|
|
// — showNotification() must be called explicitly.
|
|
self.addEventListener("push", event => {
|
|
let data = {};
|
|
try {
|
|
data = event.data ? event.data.json() : {};
|
|
} catch {
|
|
data = {};
|
|
}
|
|
const title = data.title || "Epicure";
|
|
event.waitUntil(
|
|
self.registration.showNotification(title, {
|
|
body: data.body || "",
|
|
icon: "/icon-192.svg",
|
|
badge: "/icon-192.svg",
|
|
data: { url: data.url || "/" },
|
|
})
|
|
);
|
|
});
|
|
|
|
self.addEventListener("notificationclick", event => {
|
|
event.notification.close();
|
|
const url = event.notification.data && event.notification.data.url ? event.notification.data.url : "/";
|
|
event.waitUntil(
|
|
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then(clientList => {
|
|
const targetUrl = new URL(url, self.location.origin).href;
|
|
const existing = clientList.find(c => c.url === targetUrl);
|
|
if (existing) return existing.focus();
|
|
return self.clients.openWindow(url);
|
|
})
|
|
);
|
|
});
|