feat(notifications): add account-scoped inbox and persistent preferences
CI / check (push) Failing after 1m18s
CI / deploy (push) Skipped
CI / publish-container (push) Skipped

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

+13
View File
@@ -0,0 +1,13 @@
# Notification center
Account-scoped live inbox at `/notifications`, reachable through the account menu. The authenticated session is the only source of the user ID. Source queries select only owned support tickets, incoming friend requests, and the user's published upcoming event registrations.
Each source is capped at 100 rows and the resulting feed uses server-clamped pages of 20. Read state uses the immutable support reply/request ID or event ID + registration ID + scheduled start. Repeated marks are idempotent; a new reply or rescheduled event is unread. Categories are opt-out preferences for this inbox only. No email is sent.
Persistence is additive via `0032_notification_state.sql`. Apply it through the normal deployment migration runner before enabling the page. No production migration was run during implementation.
Implementation: domain service and authorization tests; Drizzle source/state repository; strict authenticated GET/POST API; Query-powered public UI with loading, empty, retry and mutation-error states; translated en/it/nl labels; desktop/mobile fixture tests. The UI fixture verifies the real client component with mocked network responses; it does not prove live database integration.
The inbox is a bounded current feed, not a historical notification archive. Removed requests and past/unpublished/unregistered events disappear. Support includes the 100 latest replies from someone other than the ticket owner, including closed tickets. Friend request tables have no timestamp, so the UI does not invent one. Read state stays stored when a category is disabled or the source leaves the live window.
The exported component owns its query provider, matching the public route in UI tests. The visible inbox refreshes every 30 seconds and cancels abandoned requests. No browser fixture supplies hidden providers.
@@ -0,0 +1,207 @@
"use client";
import {
QueryClient,
QueryClientProvider,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { useFormatter, useTranslations } from "next-intl";
import { useState } from "react";
import Link from "@/components/link";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import type { Category, NotificationFeed, Preferences } from "./service";
export function NotificationCenter({ userId }: { userId: number }) {
const [client] = useState(
() => new QueryClient({ defaultOptions: { queries: { retry: false } } }),
);
return (
<QueryClientProvider client={client}>
<NotificationContent key={userId} userId={userId} />
</QueryClientProvider>
);
}
function NotificationContent({ userId }: { userId: number }) {
const t = useTranslations("pages.notifications");
const format = useFormatter();
const [page, setPage] = useState(1);
const client = useQueryClient();
const queryKey = ["notifications", userId];
const feed = useQuery<NotificationFeed>({
queryKey: [...queryKey, page],
queryFn: async ({ signal }) => {
const response = await fetch(`/api/notifications?page=${page}`, {
cache: "no-store",
signal,
});
if (!response.ok) throw new Error("Unavailable");
return response.json();
},
staleTime: 15_000,
refetchInterval: 30_000,
});
const mutation = useMutation({
mutationFn: async (
body:
| { action: "read"; key: string }
| { action: "preferences"; preferences: Preferences },
) => {
const response = await fetch("/api/notifications", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) throw new Error("Unavailable");
},
onSuccess: async () => {
await client.invalidateQueries({ queryKey });
},
});
const data = feed.data;
return (
<section className="page-grid" aria-label={t("title")}>
<h1 className="text-2xl font-bold">{t("title")}</h1>
<ContentCard icon="🔔" title={t("title")} subtitle={t("subtitle")}>
<p className="muted">{t("scope")}</p>
</ContentCard>
{feed.isPending ? (
<ContentCard>
<p role="status">{t("loading")}</p>
</ContentCard>
) : null}
{feed.isError ? (
<ContentCard>
<p role="alert">{t("error")}</p>
<Button variant="outline" onClick={() => feed.refetch()}>
{t("retry")}
</Button>
</ContentCard>
) : null}
{data ? (
<>
<ContentCard title={t("preferences")}>
<fieldset
disabled={mutation.isPending}
className="flex flex-wrap gap-5"
>
<legend className="sr-only">{t("preferences")}</legend>
{(["support", "friends", "events"] as Category[]).map(
(category) => (
<label
key={category}
htmlFor={`notification-${category}`}
className="flex items-center gap-2"
>
<Checkbox
id={`notification-${category}`}
style={{
borderColor: "var(--color-primary)",
backgroundColor: data.preferences[category]
? "var(--color-primary)"
: "transparent",
color: "var(--color-primary-foreground)",
}}
aria-label={t(category)}
disabled={mutation.isPending}
checked={data.preferences[category]}
onCheckedChange={(checked) =>
mutation.mutate({
action: "preferences",
preferences: {
...data.preferences,
[category]: checked,
},
})
}
/>
{t(category)}
</label>
),
)}
</fieldset>
<p className="muted mt-3">{t("preferencesHint")}</p>
</ContentCard>
<ContentCard title={t("unreadCount", { count: data.unread })}>
{mutation.isError ? <p role="alert">{t("saveError")}</p> : null}
{mutation.isSuccess ? (
<p role="status" className="muted">
{t("saved")}
</p>
) : null}
{!data.items.length ? (
<EmptyState>{t("empty")}</EmptyState>
) : (
<ul className="space-y-3">
{data.items.map((item) => (
<li
key={item.key}
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-current/10 p-4"
>
<div className="min-w-0 flex-1">
<p className="text-sm muted">
{t(item.category)} · {t(item.read ? "read" : "unread")}
</p>
<Link
href={item.href}
className="font-semibold break-words underline"
>
{item.title}
</Link>
{item.occurredAt ? (
<p className="text-sm muted">
<time dateTime={item.occurredAt}>
{format.dateTime(new Date(item.occurredAt), {
dateStyle: "medium",
timeStyle: "short",
})}
</time>
</p>
) : null}
</div>
{!item.read ? (
<Button
variant="outline"
disabled={mutation.isPending}
onClick={() =>
mutation.mutate({ action: "read", key: item.key })
}
>
{t("markRead")}
</Button>
) : null}
</li>
))}
</ul>
)}
{data.pages > 1 ? (
<nav
aria-label={t("pagination")}
className="mt-4 flex items-center gap-3"
>
<Button
variant="outline"
disabled={data.page <= 1}
onClick={() => setPage(data.page - 1)}
>
{t("previous")}
</Button>
<span>{t("page", { page: data.page, pages: data.pages })}</span>
<Button
variant="outline"
disabled={data.page >= data.pages}
onClick={() => setPage(data.page + 1)}
>
{t("next")}
</Button>
</nav>
) : null}
</ContentCard>
</>
) : null}
</section>
);
}
+26
View File
@@ -0,0 +1,26 @@
import {
boolean,
int,
mysqlTable,
primaryKey,
timestamp,
varchar,
} from "drizzle-orm/mysql-core";
export const NotificationRead = mysqlTable(
"website_notification_reads",
{
userId: int("user_id").notNull(),
eventKey: varchar("event_key", { length: 128 }).notNull(),
readAt: timestamp("read_at").notNull().defaultNow(),
},
(table) => [primaryKey({ columns: [table.userId, table.eventKey] })],
);
export const NotificationPreference = mysqlTable(
"website_notification_preferences",
{
userId: int("user_id").primaryKey().notNull(),
support: boolean("support").notNull().default(true),
friends: boolean("friends").notNull().default(true),
events: boolean("events").notNull().default(true),
},
);
+139
View File
@@ -0,0 +1,139 @@
import "server-only";
import { and, asc, desc, eq, gt, inArray, ne } from "drizzle-orm";
import {
db,
MessengerFriendrequests,
User,
WebsiteEvent,
WebsiteEventRegistration,
WebsiteHelpCenterTicketReplies,
WebsiteHelpCenterTickets,
} from "@/lib/db";
import { NotificationPreference, NotificationRead } from "./schema";
import {
createNotificationService,
type Notification,
type NotificationRepository,
} from "./service";
// A bounded live inbox: up to 100 items per source, not an archival message store.
export const notificationRepository: NotificationRepository = {
async source(userId) {
const [support, friends, events] = await Promise.all([
db
.select({
id: WebsiteHelpCenterTicketReplies.id,
ticketId: WebsiteHelpCenterTickets.id,
title: WebsiteHelpCenterTickets.title,
createdAt: WebsiteHelpCenterTicketReplies.createdAt,
})
.from(WebsiteHelpCenterTicketReplies)
.innerJoin(
WebsiteHelpCenterTickets,
eq(
WebsiteHelpCenterTickets.id,
WebsiteHelpCenterTicketReplies.ticketId,
),
)
.where(
and(
eq(WebsiteHelpCenterTickets.userId, userId),
ne(WebsiteHelpCenterTicketReplies.userId, userId),
),
)
.orderBy(desc(WebsiteHelpCenterTicketReplies.id))
.limit(100),
db
.select({ id: MessengerFriendrequests.id, username: User.username })
.from(MessengerFriendrequests)
.innerJoin(User, eq(User.id, MessengerFriendrequests.userFromId))
.where(eq(MessengerFriendrequests.userToId, userId))
.orderBy(desc(MessengerFriendrequests.id))
.limit(100),
db
.select({
id: WebsiteEvent.id,
title: WebsiteEvent.title,
startsAt: WebsiteEvent.startsAt,
registrationId: WebsiteEventRegistration.id,
})
.from(WebsiteEventRegistration)
.innerJoin(
WebsiteEvent,
eq(WebsiteEvent.id, WebsiteEventRegistration.eventId),
)
.where(
and(
eq(WebsiteEventRegistration.userId, userId),
eq(WebsiteEvent.status, "published"),
gt(WebsiteEvent.startsAt, new Date()),
),
)
.orderBy(asc(WebsiteEvent.startsAt), asc(WebsiteEvent.id))
.limit(100),
]);
const items: Notification[] = [
...support.map((row) => ({
key: `support:${row.id}`,
category: "support" as const,
title: row.title,
href: `/help/tickets/${row.ticketId}`,
occurredAt: row.createdAt?.toISOString() ?? null,
})),
...friends.map((row) => ({
key: `friends:${row.id}`,
category: "friends" as const,
title: row.username,
href: "/friends",
occurredAt: null,
})),
...events.map((row) => ({
key: `events:${row.id}:${row.registrationId}:${row.startsAt.getTime()}`,
category: "events" as const,
title: row.title,
href: `/events?mine=1`,
occurredAt: row.startsAt.toISOString(),
})),
];
return items;
},
async reads(userId, keys) {
if (!keys.length) return [];
return (
await db
.select({ key: NotificationRead.eventKey })
.from(NotificationRead)
.where(
and(
eq(NotificationRead.userId, userId),
inArray(NotificationRead.eventKey, keys),
),
)
).map((row) => row.key);
},
async mark(userId, key) {
await db
.insert(NotificationRead)
.values({ userId, eventKey: key })
.onDuplicateKeyUpdate({ set: { eventKey: key } });
},
async preferences(userId) {
const [row] = await db
.select({
support: NotificationPreference.support,
friends: NotificationPreference.friends,
events: NotificationPreference.events,
})
.from(NotificationPreference)
.where(eq(NotificationPreference.userId, userId))
.limit(1);
return row ?? { support: true, friends: true, events: true };
},
async savePreferences(userId, preferences) {
await db
.insert(NotificationPreference)
.values({ userId, ...preferences })
.onDuplicateKeyUpdate({ set: preferences });
},
};
export const notifications = createNotificationService(notificationRepository);
+106
View File
@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import {
createNotificationService,
type NotificationRepository,
type Preferences,
} from "./service";
const item = {
key: "support:4",
category: "support" as const,
title: "Ticket",
href: "/help/tickets/2",
occurredAt: null,
};
function fixture() {
const reads = new Set<string>();
const preferences = new Map<number, Preferences>();
let items = [item];
const repository: NotificationRepository = {
source: async (uid) => (uid === 1 ? items : []),
reads: async (uid) =>
[...reads]
.filter((k) => k.startsWith(`${uid}/`))
.map((k) => k.split("/")[1]),
mark: async (uid, key) => {
reads.add(`${uid}/${key}`);
},
preferences: async (uid) =>
preferences.get(uid) ?? { support: true, friends: true, events: true },
savePreferences: async (uid, value) => {
preferences.set(uid, value);
},
};
return {
service: createNotificationService(repository),
reads,
reply: () => {
items = [{ ...item, key: "support:5" }];
},
many: () => {
items = Array.from({ length: 45 }, (_, i) => ({
...item,
key: `support:${i}`,
}));
},
};
}
describe("notification account isolation", () => {
it("rejects invalid account identifiers for every operation", async () => {
const { service } = fixture();
for (const uid of [0, -1, Number.NaN, 1.5, Number.MAX_SAFE_INTEGER + 1]) {
await expect(service.feed(uid, 1)).rejects.toThrow();
await expect(service.mark(uid, item.key)).rejects.toThrow();
await expect(
service.preferences(uid, {
support: true,
friends: true,
events: true,
}),
).rejects.toThrow();
}
});
it("cannot read or mark another account's notification", async () => {
const f = fixture();
expect((await f.service.feed(2, 1)).items).toEqual([]);
expect(await f.service.mark(2, item.key)).toBe(false);
expect(f.reads.size).toBe(0);
});
it("persists idempotent reads but a new reply remains unread", async () => {
const f = fixture();
await f.service.mark(1, item.key);
await f.service.mark(1, item.key);
expect(f.reads.size).toBe(1);
expect((await f.service.feed(1, 1)).items[0].read).toBe(true);
f.reply();
expect((await f.service.feed(1, 1)).items[0].read).toBe(false);
});
it("isolates preferences and retains reads across disabled categories", async () => {
const f = fixture();
await f.service.mark(1, item.key);
await f.service.preferences(1, {
support: false,
friends: true,
events: true,
});
const hidden = await f.service.feed(1, 1);
expect(hidden.items).toEqual([]);
expect(hidden.unread).toBe(0);
expect((await f.service.feed(2, 1)).preferences.support).toBe(true);
await f.service.preferences(1, {
support: true,
friends: true,
events: true,
});
expect((await f.service.feed(1, 1)).items[0].read).toBe(true);
});
it("paginates the feed with total unread count and clamps hostile page numbers", async () => {
const f = fixture();
f.many();
expect((await f.service.feed(1, Number.POSITIVE_INFINITY)).page).toBe(1);
const last = await f.service.feed(1, 999);
expect(last.page).toBe(3);
expect(last.items).toHaveLength(5);
expect(last.unread).toBe(45);
});
});
+64
View File
@@ -0,0 +1,64 @@
export type Category = "support" | "friends" | "events";
export type Preferences = Record<Category, boolean>;
export type Notification = {
key: string;
category: Category;
title: string;
href: string;
occurredAt: string | null;
};
export interface NotificationRepository {
source(userId: number): Promise<Notification[]>;
reads(userId: number, keys: string[]): Promise<string[]>;
mark(userId: number, key: string): Promise<void>;
preferences(userId: number): Promise<Preferences>;
savePreferences(userId: number, preferences: Preferences): Promise<void>;
}
function account(userId: number) {
if (!Number.isSafeInteger(userId) || userId < 1)
throw new Error("Invalid user");
}
export function createNotificationService(repo: NotificationRepository) {
return {
async feed(userId: number, requestedPage: number) {
account(userId);
const [source, preferences] = await Promise.all([
repo.source(userId),
repo.preferences(userId),
]);
const reads = await repo.reads(
userId,
source.map((item) => item.key),
);
const read = new Set(reads);
const items = source
.filter((item) => preferences[item.category])
.map((item) => ({ ...item, read: read.has(item.key) }));
const pages = Math.max(1, Math.ceil(items.length / 20));
const page = Number.isSafeInteger(requestedPage)
? Math.max(1, Math.min(pages, requestedPage))
: 1;
return {
items: items.slice((page - 1) * 20, page * 20),
page,
pages,
unread: items.filter((item) => !item.read).length,
preferences,
};
},
async mark(userId: number, key: string) {
account(userId);
if (!(await repo.source(userId)).some((item) => item.key === key))
return false;
await repo.mark(userId, key);
return true;
},
async preferences(userId: number, preferences: Preferences) {
account(userId);
await repo.savePreferences(userId, preferences);
},
};
}
export type NotificationFeed = Awaited<
ReturnType<ReturnType<typeof createNotificationService>["feed"]>
>;