rspace-online/server/notification-routes.ts

132 lines
4.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,
} 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) => {
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 });
});
// ── GET /count — Lightweight unread count (polling fallback) ──
notificationRouter.get("/count", async (c) => {
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 });
});
// ── 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 });
});