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.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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),
|
||||
},
|
||||
);
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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