rspace-online/server/notification-routes.ts

190 lines
6.1 KiB
TypeScript

/**
* 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 });
});