feat(notifications): add account-scoped inbox and persistent preferences

This commit is contained in:
Simo committed 2026-09-13 17:59:22 +02:00
1 parent 8f55ff2d17
commit 1eee6661f9
39 files changed
+1559 -3

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { NotificationCenter } from "@/features/notifications/notification-center";
import { auth } from "@/lib/auth";
import { sessionUserId } from "@/lib/auth/session-user";
export async function generateMetadata() {
const t = await getTranslations("pages.notifications");
return { title: t("title"), robots: { index: false, follow: false } };
}
export default async function NotificationsPage() {
const uid = sessionUserId((await auth())?.user?.id);
if (!uid) redirect("/login");
return <NotificationCenter userId={uid} />;
}
File diff suppressed because it is too large. Load diff
+67
View File
@@ -0,0 +1,67 @@
import { z } from "zod";
import { notifications } from "@/features/notifications/server";
import { apiError, apiJson, apiUnavailable } from "@/lib/api";
import { auth } from "@/lib/auth";
import { sessionUserId } from "@/lib/auth/session-user";
import { rateLimit } from "@/lib/rate-limit";
const mutation = z.discriminatedUnion("action", [
z
.object({ action: z.literal("read"), key: z.string().min(1).max(128) })
.strict(),
z
.object({
action: z.literal("preferences"),
preferences: z
.object({
support: z.boolean(),
friends: z.boolean(),
events: z.boolean(),
})
.strict(),
})
.strict(),
]);
export async function GET(req: Request) {
const uid = sessionUserId((await auth())?.user?.id);
if (!uid) return apiError("Unauthorized", 401);
try {
return apiJson(
await notifications.feed(
uid,
Number(new URL(req.url).searchParams.get("page") ?? 1),
),
);
} catch {
return apiUnavailable();
}
}
export async function POST(req: Request) {
const uid = sessionUserId((await auth())?.user?.id);
if (!uid) return apiError("Unauthorized", 401);
if (req.headers.get("origin") !== new URL(req.url).origin)
return apiError("Invalid origin", 403);
if (!req.headers.get("content-type")?.startsWith("application/json"))
return apiError("Invalid content type", 415);
if (!(await rateLimit(`notifications:${uid}`, 60, 60_000)).ok)
return apiError("Too many requests", 429);
const body = await req.text();
if (body.length > 2048) return apiError("Request too large", 413);
let value: unknown;
try {
value = JSON.parse(body);
} catch {
return apiError("Invalid request");
}
const parsed = mutation.safeParse(value);
if (!parsed.success) return apiError("Invalid request");
try {
if (parsed.data.action === "read") {
if (!(await notifications.mark(uid, parsed.data.key)))
return apiError("Notification not found", 404);
} else await notifications.preferences(uid, parsed.data.preferences);
return apiJson({ ok: true });
} catch {
return apiUnavailable();
}
}