feat(notifications): add account-scoped inbox and persistent preferences
This commit is contained in:
1 parent
8f55ff2d17
commit
1eee6661f9
39 files changed
+1559
-3
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"]>
|
||||
>;
|
||||
Reference in new issue
Block a user