/** * Notification REST API — mounted at /api/notifications * * All endpoints require Bearer auth (same pattern as spaces.ts). */ import { Hono } from "hono"; import { verifyEncryptIDToken, extractToken, } from "@encryptid/sdk/server"; import { getUserNotifications, getUnreadCount, markNotificationRead, markAllNotificationsRead, dismissNotification, getNotificationPreferences, upsertNotificationPreferences, savePushSubscription, deletePushSubscriptionByEndpoint, } from "../src/encryptid/db"; export const notificationRouter = new Hono(); // ── Auth helper ── async function requireAuth(req: Request) { const token = extractToken(req.headers); if (!token) return null; try { return await verifyEncryptIDToken(token); } catch { return null; } } // ── GET / — Paginated notification list ── notificationRouter.get("/", async (c) => { try { const claims = await requireAuth(c.req.raw); if (!claims) return c.json({ error: "Authentication required" }, 401); const url = new URL(c.req.url); const unreadOnly = url.searchParams.get("unread") === "true"; const category = url.searchParams.get("category") || undefined; const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 100); const offset = Number(url.searchParams.get("offset")) || 0; const notifications = await getUserNotifications(claims.sub, { unreadOnly, category, limit, offset, }); return c.json({ notifications }); } catch (err) { console.error("[notifications] GET / failed:", err instanceof Error ? err.message : err); return c.json({ notifications: [] }); } }); // ── GET /count — Lightweight unread count (polling fallback) ── notificationRouter.get("/count", async (c) => { try { const claims = await requireAuth(c.req.raw); if (!claims) return c.json({ error: "Authentication required" }, 401); const count = await getUnreadCount(claims.sub); return c.json({ unreadCount: count }); } catch (err) { console.error("[notifications] GET /count failed:", err instanceof Error ? err.message : err); return c.json({ unreadCount: 0 }); } }); // ── PATCH /:id/read — Mark one notification as read ── notificationRouter.patch("/:id/read", async (c) => { const claims = await requireAuth(c.req.raw); if (!claims) return c.json({ error: "Authentication required" }, 401); const id = c.req.param("id"); const ok = await markNotificationRead(id, claims.sub); if (!ok) return c.json({ error: "Notification not found" }, 404); return c.json({ ok: true }); }); // ── POST /read-all — Mark all read (optional scope) ── notificationRouter.post("/read-all", async (c) => { const claims = await requireAuth(c.req.raw); if (!claims) return c.json({ error: "Authentication required" }, 401); let spaceSlug: string | undefined; let category: string | undefined; try { const body = await c.req.json(); spaceSlug = body.spaceSlug; category = body.category; } catch { // No body is fine — mark everything read } const count = await markAllNotificationsRead(claims.sub, { spaceSlug, category }); return c.json({ ok: true, markedRead: count }); }); // ── DELETE /:id — Dismiss/archive a notification ── notificationRouter.delete("/:id", async (c) => { const claims = await requireAuth(c.req.raw); if (!claims) return c.json({ error: "Authentication required" }, 401); const id = c.req.param("id"); const ok = await dismissNotification(id, claims.sub); if (!ok) return c.json({ error: "Notification not found" }, 404); return c.json({ ok: true }); }); // ── GET /preferences — Get notification preferences ── notificationRouter.get("/preferences", async (c) => { const claims = await requireAuth(c.req.raw); if (!claims) return c.json({ error: "Authentication required" }, 401); const prefs = await getNotificationPreferences(claims.sub); return c.json({ preferences: prefs || { emailEnabled: true, pushEnabled: true, quietHoursStart: null, quietHoursEnd: null, mutedSpaces: [], mutedCategories: [], digestFrequency: 'none', }}); }); // ── PATCH /preferences — Update notification preferences ── notificationRouter.patch("/preferences", async (c) => { const claims = await requireAuth(c.req.raw); if (!claims) return c.json({ error: "Authentication required" }, 401); const body = await c.req.json(); const prefs = await upsertNotificationPreferences(claims.sub, body); return c.json({ preferences: prefs }); }); // ============================================================================ // WEB PUSH ENDPOINTS // ============================================================================ // ── GET /push/vapid-public-key — Return public VAPID key (no auth) ── notificationRouter.get("/push/vapid-public-key", (c) => { const key = process.env.VAPID_PUBLIC_KEY; if (!key) return c.json({ error: "Push not configured" }, 503); return c.json({ publicKey: key }); }); // ── POST /push/subscribe — Save push subscription (auth required) ── notificationRouter.post("/push/subscribe", async (c) => { const claims = await requireAuth(c.req.raw); if (!claims) return c.json({ error: "Authentication required" }, 401); const body = await c.req.json(); const { endpoint, keys } = body; if (!endpoint || !keys?.p256dh || !keys?.auth) { return c.json({ error: "Invalid subscription object" }, 400); } await savePushSubscription({ id: crypto.randomUUID(), userDid: claims.sub, endpoint, keyP256dh: keys.p256dh, keyAuth: keys.auth, userAgent: c.req.header("user-agent"), }); return c.json({ ok: true }); }); // ── POST /push/unsubscribe — Remove push subscription (auth required) ── notificationRouter.post("/push/unsubscribe", async (c) => { const claims = await requireAuth(c.req.raw); if (!claims) return c.json({ error: "Authentication required" }, 401); const body = await c.req.json(); if (!body.endpoint) return c.json({ error: "endpoint required" }, 400); await deletePushSubscriptionByEndpoint(body.endpoint); return c.json({ ok: true }); });