diff --git a/drizzle/migrations/0032_notification_state.sql b/drizzle/migrations/0032_notification_state.sql new file mode 100644 index 00000000..cdfcf70e --- /dev/null +++ b/drizzle/migrations/0032_notification_state.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS `website_notification_reads` ( + `user_id` int NOT NULL, + `event_key` varchar(128) NOT NULL, + `read_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`user_id`, `event_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS `website_notification_preferences` ( + `user_id` int NOT NULL, + `support` boolean NOT NULL DEFAULT true, + `friends` boolean NOT NULL DEFAULT true, + `events` boolean NOT NULL DEFAULT true, + PRIMARY KEY (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/e2e/ui/fixture-server.mjs b/e2e/ui/fixture-server.mjs index 547d9f6c..972ae263 100644 --- a/e2e/ui/fixture-server.mjs +++ b/e2e/ui/fixture-server.mjs @@ -197,6 +197,7 @@ const server = createServer(async (req, res) => { } if ( [ + "/notifications", "/admin/unsaved-harness", "/admin/attachment-harness", "/admin/studio-harness", diff --git a/e2e/ui/fixtures/entry.tsx b/e2e/ui/fixtures/entry.tsx index 17d0f77a..caffff6c 100644 --- a/e2e/ui/fixtures/entry.tsx +++ b/e2e/ui/fixtures/entry.tsx @@ -8,6 +8,7 @@ import { TicketsTable } from "@/app/admin/tickets/tickets-table"; import { ArticleForm } from "@/components/admin/article-form"; import { FurnitureSyncQueue } from "@/components/admin/studio/furniture-sync-queue"; import { StudioClient } from "@/components/admin/studio/studio-client"; +import { NotificationCenter } from "@/features/notifications/notification-center"; import type { ArticleSaveResult } from "@/lib/article-input"; import { buildFurniImportSource } from "@/lib/habbo-gamedata-hotel"; import { themePaletteCss } from "@/lib/theme-css"; @@ -25,6 +26,11 @@ theme.textContent = themePaletteCss( ); document.head.appendChild(theme); const route = window.location.pathname; +if (route.includes("notifications")) { + document.documentElement.classList.add("app"); + document.body.classList.add("site-bg"); + document.body.style.backgroundColor = "var(--color-background)"; +} const root = document.getElementById("root"); if (!root) throw Error("Fixture root missing"); const rows = [ @@ -96,8 +102,18 @@ createRoot(root).render( now={new Date("2030-01-01T12:00:00Z")} messages={messages} > -
-

+
+

{route.includes("settings") ? "CMS settings" : route.includes("tickets") @@ -106,7 +122,9 @@ createRoot(root).render( ? "News editor" : "Furniture synchronization"}

- {route.includes("unsaved-harness") ? ( + {route.includes("notifications") ? ( + + ) : route.includes("unsaved-harness") ? ( ) : route.includes("studio-harness") ? ( { + page.on("pageerror", (error) => { + throw error; + }); +}); + +test("notification reads persist, new replies are unread and failed preferences stay unchanged", async ({ + page, +}) => { + let key = "support:42"; + const readKeys = new Set(); + let fail = false; + await page.route("**/api/notifications**", async (route) => { + if (route.request().method() === "POST") { + if (fail) + return route.fulfill({ status: 503, json: { error: "Unavailable" } }); + const body = route.request().postDataJSON(); + if (body.action === "read") readKeys.add(body.key); + return route.fulfill({ json: { ok: true } }); + } + return route.fulfill({ + json: { + items: [ + { + key, + category: "support", + title: "Your account request", + href: "/help/tickets/2", + occurredAt: "2030-01-01T12:00:00Z", + read: readKeys.has(key), + }, + ], + page: 1, + pages: 1, + unread: readKeys.has(key) ? 0 : 1, + preferences: { support: true, friends: true, events: true }, + }, + }); + }); + await page.goto("/notifications"); + const mark = page.getByRole("button", { name: "Mark as read" }); + await expect(mark).toBeVisible(); + await mark.focus(); + await expect(mark).toBeFocused(); + await page.keyboard.press("Enter"); + await expect(mark).toHaveCount(0); + await page.reload(); + await expect( + page.getByText("No unread notifications", { exact: true }), + ).toBeVisible(); + key = "support:43"; + await page.reload(); + await expect(mark).toBeVisible(); + fail = true; + await mark.click(); + await expect(page.getByRole("alert")).toHaveText( + "The change could not be saved. Please try again.", + ); + await expect(mark).toBeVisible(); + const support = page.getByRole("checkbox", { name: "Support replies" }); + await support.click(); + await expect(support).toBeChecked(); + expect(await support.evaluate((el) => getComputedStyle(el).color)).not.toBe( + "rgba(0, 0, 0, 0)", + ); + expect( + await support.evaluate((el) => getComputedStyle(el).backgroundColor), + ).not.toBe("rgba(0, 0, 0, 0)"); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth, + ), + ).toBe(true); + await page.screenshot({ + path: `test-results/notifications-${test.info().project.name}.png`, + fullPage: true, + }); +}); + +test("notification service failure offers a retry and recovers to empty state", async ({ + page, +}) => { + let failing = true; + await page.route("**/api/notifications**", (route) => + route.fulfill( + failing + ? { status: 503, json: { error: "Unavailable" } } + : { + json: { + items: [], + page: 1, + pages: 1, + unread: 0, + preferences: { support: true, friends: true, events: true }, + }, + }, + ), + ); + await page.goto("/notifications"); + await expect(page.getByRole("alert")).toHaveText( + "Notifications are temporarily unavailable.", + ); + failing = false; + await page.getByRole("button", { name: "Try again" }).click(); + await expect( + page.getByText("No notifications in your enabled categories."), + ).toBeVisible(); +}); + +test("category changes persist and pagination recovers when filtering shrinks the feed", async ({ + page, +}) => { + let enabled = true; + await page.route("**/api/notifications**", async (route) => { + if (route.request().method() === "POST") { + enabled = route.request().postDataJSON().preferences.support; + return route.fulfill({ json: { ok: true } }); + } + const requested = Number( + new URL(route.request().url()).searchParams.get("page") ?? 1, + ); + const current = enabled ? Math.min(2, requested) : 1; + return route.fulfill({ + json: { + items: enabled + ? [ + { + key: `support:${current}`, + category: "support", + title: `Reply page ${current}`, + href: "/help/tickets/2", + occurredAt: null, + read: false, + }, + ] + : [], + page: current, + pages: enabled ? 2 : 1, + unread: enabled ? 21 : 0, + preferences: { support: enabled, friends: true, events: true }, + }, + }); + }); + await page.goto("/notifications"); + await page.getByRole("button", { name: "Next", exact: true }).click(); + await expect(page.getByRole("link", { name: "Reply page 2" })).toBeVisible(); + const support = page.getByRole("checkbox", { name: "Support replies" }); + await support.click(); + await expect(support).not.toBeChecked(); + await expect( + page.getByText("No notifications in your enabled categories."), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Next", exact: true }), + ).toHaveCount(0); + await page.reload(); + await expect(support).not.toBeChecked(); + await support.click(); + await expect(page.getByRole("link", { name: "Reply page 1" })).toBeVisible(); +}); diff --git a/src/app/(site)/notifications/page.tsx b/src/app/(site)/notifications/page.tsx new file mode 100644 index 00000000..2697db6e --- /dev/null +++ b/src/app/(site)/notifications/page.tsx @@ -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 ; +} diff --git a/src/app/api/notifications/route.test.ts b/src/app/api/notifications/route.test.ts new file mode 100644 index 00000000..01f2933b --- /dev/null +++ b/src/app/api/notifications/route.test.ts @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + auth: vi.fn(), + feed: vi.fn(), + mark: vi.fn(), + preferences: vi.fn(), +})); +vi.mock("@/lib/auth", () => ({ auth: mocks.auth })); +vi.mock("@/features/notifications/server", () => ({ notifications: mocks })); +vi.mock("@/lib/rate-limit", () => ({ rateLimit: async () => ({ ok: true }) })); + +import { GET, POST } from "./route"; + +function request(body: unknown, origin = "https://hotel.test") { + return new Request("https://hotel.test/api/notifications", { + method: "POST", + headers: { origin, "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} +beforeEach(() => { + vi.clearAllMocks(); + mocks.auth.mockResolvedValue({ user: { id: "7" } }); + mocks.feed.mockResolvedValue({ items: [] }); + mocks.mark.mockResolvedValue(true); +}); +describe("notification route authorization", () => { + it("rejects anonymous reads and writes without accessing data", async () => { + mocks.auth.mockResolvedValue(null); + expect( + (await GET(new Request("https://hotel.test/api/notifications"))).status, + ).toBe(401); + expect( + (await POST(request({ action: "read", key: "support:1" }))).status, + ).toBe(401); + expect(mocks.feed).not.toHaveBeenCalled(); + expect(mocks.mark).not.toHaveBeenCalled(); + }); + it("uses the session identity even when a query supplies another account", async () => { + await GET( + new Request("https://hotel.test/api/notifications?userId=99&page=2"), + ); + expect(mocks.feed).toHaveBeenCalledWith(7, 2); + }); + it("rejects cross-origin mutations", async () => { + expect( + ( + await POST( + request( + { action: "read", key: "support:1" }, + "https://attacker.test", + ), + ) + ).status, + ).toBe(403); + expect(mocks.mark).not.toHaveBeenCalled(); + }); + it("rejects an injected account in writes", async () => { + expect( + (await POST(request({ action: "read", key: "support:1", userId: 99 }))) + .status, + ).toBe(400); + expect(mocks.mark).not.toHaveBeenCalled(); + }); + it("rejects unavailable and other account notifications", async () => { + mocks.mark.mockResolvedValue(false); + expect( + (await POST(request({ action: "read", key: "support:1" }))).status, + ).toBe(404); + expect(mocks.mark).toHaveBeenCalledWith(7, "support:1"); + }); + it("persists category preferences under the authenticated account", async () => { + const preferences = { support: false, friends: true, events: false }; + expect( + (await POST(request({ action: "preferences", preferences }))).status, + ).toBe(200); + expect(mocks.preferences).toHaveBeenCalledWith(7, preferences); + }); + it("does not turn an infrastructure failure into an empty feed", async () => { + mocks.feed.mockRejectedValue(new Error("Database unavailable")); + expect( + (await GET(new Request("https://hotel.test/api/notifications"))).status, + ).toBe(503); + }); +}); + +it("returns a failure when saving read state or preferences fails", async () => { + mocks.mark.mockRejectedValue(new Error("Database unavailable")); + expect( + (await POST(request({ action: "read", key: "support:1" }))).status, + ).toBe(503); + mocks.preferences.mockRejectedValue(new Error("Database unavailable")); + expect( + ( + await POST( + request({ + action: "preferences", + preferences: { support: true, friends: false, events: true }, + }), + ) + ).status, + ).toBe(503); +}); + +it("rejects incomplete and foreign-account preferences", async () => { + for (const body of [ + { action: "preferences", preferences: { support: false } }, + { + action: "preferences", + userId: 99, + preferences: { support: false, friends: true, events: true }, + }, + ]) + expect((await POST(request(body))).status).toBe(400); + expect(mocks.preferences).not.toHaveBeenCalled(); +}); diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts new file mode 100644 index 00000000..d89561c2 --- /dev/null +++ b/src/app/api/notifications/route.ts @@ -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(); + } +} diff --git a/src/components/top-header.tsx b/src/components/top-header.tsx index 81d05467..1d7b2093 100644 --- a/src/components/top-header.tsx +++ b/src/components/top-header.tsx @@ -1,6 +1,7 @@ import { count, eq, inArray } from "drizzle-orm"; import Image from "next/image"; import type { Session } from "next-auth"; +import { getTranslations } from "next-intl/server"; import { signOutAndRevokeTicket } from "@/actions/sessions"; import { HeaderUserIdentity } from "@/components/header-user-identity"; import Link from "@/components/link"; @@ -53,6 +54,7 @@ function Currency({ export async function TopHeader({ session }: { session: Session | null }) { if (!session?.user?.id) return null; + const notificationText = await getTranslations("pages.notifications"); const id = Number(session.user.id); let credits = 0; @@ -343,6 +345,13 @@ export async function TopHeader({ session }: { session: Session | null }) { > My profile + + {notificationText("title")} + User settings diff --git a/src/features/notifications/README.md b/src/features/notifications/README.md new file mode 100644 index 00000000..8c009333 --- /dev/null +++ b/src/features/notifications/README.md @@ -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. diff --git a/src/features/notifications/notification-center.tsx b/src/features/notifications/notification-center.tsx new file mode 100644 index 00000000..b98a7f56 --- /dev/null +++ b/src/features/notifications/notification-center.tsx @@ -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 ( + + + + ); +} + +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({ + 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 ( +
+

{t("title")}

+ +

{t("scope")}

+
+ {feed.isPending ? ( + +

{t("loading")}

+
+ ) : null} + {feed.isError ? ( + +

{t("error")}

+ +
+ ) : null} + {data ? ( + <> + +
+ {t("preferences")} + {(["support", "friends", "events"] as Category[]).map( + (category) => ( + + ), + )} +
+

{t("preferencesHint")}

+
+ + {mutation.isError ?

{t("saveError")}

: null} + {mutation.isSuccess ? ( +

+ {t("saved")} +

+ ) : null} + {!data.items.length ? ( + {t("empty")} + ) : ( +
    + {data.items.map((item) => ( +
  • +
    +

    + {t(item.category)} · {t(item.read ? "read" : "unread")} +

    + + {item.title} + + {item.occurredAt ? ( +

    + +

    + ) : null} +
    + {!item.read ? ( + + ) : null} +
  • + ))} +
+ )} + {data.pages > 1 ? ( + + ) : null} +
+ + ) : null} +
+ ); +} diff --git a/src/features/notifications/schema.ts b/src/features/notifications/schema.ts new file mode 100644 index 00000000..dd2fe369 --- /dev/null +++ b/src/features/notifications/schema.ts @@ -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), + }, +); diff --git a/src/features/notifications/server.ts b/src/features/notifications/server.ts new file mode 100644 index 00000000..5a6a1e71 --- /dev/null +++ b/src/features/notifications/server.ts @@ -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); diff --git a/src/features/notifications/service.test.ts b/src/features/notifications/service.test.ts new file mode 100644 index 00000000..0598ad6a --- /dev/null +++ b/src/features/notifications/service.test.ts @@ -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(); + const preferences = new Map(); + 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); + }); +}); diff --git a/src/features/notifications/service.ts b/src/features/notifications/service.ts new file mode 100644 index 00000000..fba95d8e --- /dev/null +++ b/src/features/notifications/service.ts @@ -0,0 +1,64 @@ +export type Category = "support" | "friends" | "events"; +export type Preferences = Record; +export type Notification = { + key: string; + category: Category; + title: string; + href: string; + occurredAt: string | null; +}; +export interface NotificationRepository { + source(userId: number): Promise; + reads(userId: number, keys: string[]): Promise; + mark(userId: number, key: string): Promise; + preferences(userId: number): Promise; + savePreferences(userId: number, preferences: Preferences): Promise; +} +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["feed"]> +>; diff --git a/src/messages/ar.json b/src/messages/ar.json index 9f1f4334..ce1417ed 100644 --- a/src/messages/ar.json +++ b/src/messages/ar.json @@ -4820,6 +4820,30 @@ "previous": "Previous", "next": "Next", "pageSummary": "Page {page} of {pages} · {total} results" + }, + "notifications": { + "title": "Notifications", + "subtitle": "Your support replies, friend requests and registered events.", + "scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.", + "loading": "Loading notifications…", + "error": "Notifications are temporarily unavailable.", + "retry": "Try again", + "preferences": "Notification preferences", + "preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.", + "support": "Support replies", + "friends": "Friend requests", + "events": "Upcoming events", + "unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}", + "saveError": "The change could not be saved. Please try again.", + "saved": "Change saved.", + "empty": "No notifications in your enabled categories.", + "read": "Read", + "unread": "Unread", + "markRead": "Mark as read", + "pagination": "Notification pages", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {pages}" } }, "toolbar": { diff --git a/src/messages/bg.json b/src/messages/bg.json index bb4005a8..799237fd 100644 --- a/src/messages/bg.json +++ b/src/messages/bg.json @@ -4816,6 +4816,30 @@ "previous": "Previous", "next": "Next", "pageSummary": "Page {page} of {pages} · {total} results" + }, + "notifications": { + "title": "Notifications", + "subtitle": "Your support replies, friend requests and registered events.", + "scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.", + "loading": "Loading notifications…", + "error": "Notifications are temporarily unavailable.", + "retry": "Try again", + "preferences": "Notification preferences", + "preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.", + "support": "Support replies", + "friends": "Friend requests", + "events": "Upcoming events", + "unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}", + "saveError": "The change could not be saved. Please try again.", + "saved": "Change saved.", + "empty": "No notifications in your enabled categories.", + "read": "Read", + "unread": "Unread", + "markRead": "Mark as read", + "pagination": "Notification pages", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {pages}" } }, "emails": { diff --git a/src/messages/cs.json b/src/messages/cs.json index daec1faf..cc219e5d 100644 --- a/src/messages/cs.json +++ b/src/messages/cs.json @@ -4816,6 +4816,30 @@ "previous": "Previous", "next": "Next", "pageSummary": "Page {page} of {pages} · {total} results" + }, + "notifications": { + "title": "Notifications", + "subtitle": "Your support replies, friend requests and registered events.", + "scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.", + "loading": "Loading notifications…", + "error": "Notifications are temporarily unavailable.", + "retry": "Try again", + "preferences": "Notification preferences", + "preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.", + "support": "Support replies", + "friends": "Friend requests", + "events": "Upcoming events", + "unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}", + "saveError": "The change could not be saved. Please try again.", + "saved": "Change saved.", + "empty": "No notifications in your enabled categories.", + "read": "Read", + "unread": "Unread", + "markRead": "Mark as read", + "pagination": "Notification pages", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {pages}" } }, "emails": { diff --git a/src/messages/da.json b/src/messages/da.json index ccc98522..55b666a2 100644 --- a/src/messages/da.json +++ b/src/messages/da.json @@ -4816,6 +4816,30 @@ "previous": "Previous", "next": "Next", "pageSummary": "Page {page} of {pages} · {total} results" + }, + "notifications": { + "title": "Notifications", + "subtitle": "Your support replies, friend requests and registered events.", + "scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.", + "loading": "Loading notifications…", + "error": "Notifications are temporarily unavailable.", + "retry": "Try again", + "preferences": "Notification preferences", + "preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.", + "support": "Support replies", + "friends": "Friend requests", + "events": "Upcoming events", + "unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}", + "saveError": "The change could not be saved. Please try again.", + "saved": "Change saved.", + "empty": "No notifications in your enabled categories.", + "read": "Read", + "unread": "Unread", + "markRead": "Mark as read", + "pagination": "Notification pages", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {pages}" } }, "emails": { diff --git a/src/messages/de.json b/src/messages/de.json index e65867d4..9cd97b3a 100644 --- a/src/messages/de.json +++ b/src/messages/de.json @@ -4816,6 +4816,30 @@ "previous": "Previous", "next": "Next", "pageSummary": "Page {page} of {pages} · {total} results" + }, + "notifications": { + "title": "Notifications", + "subtitle": "Your support replies, friend requests and registered events.", + "scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.", + "loading": "Loading notifications…", + "error": "Notifications are temporarily unavailable.", + "retry": "Try again", + "preferences": "Notification preferences", + "preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.", + "support": "Support replies", + "friends": "Friend requests", + "events": "Upcoming events", + "unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}", + "saveError": "The change could not be saved. Please try again.", + "saved": "Change saved.", + "empty": "No notifications in your enabled categories.", + "read": "Read", + "unread": "Unread", + "markRead": "Mark as read", + "pagination": "Notification pages", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {pages}" } }, "emails": { diff --git a/src/messages/el.json b/src/messages/el.json index f5d09383..b51a2f66 100644 --- a/src/messages/el.json +++ b/src/messages/el.json @@ -4816,6 +4816,30 @@ "previous": "Previous", "next": "Next", "pageSummary": "Page {page} of {pages} · {total} results" + }, + "notifications": { + "title": "Notifications", + "subtitle": "Your support replies, friend requests and registered events.", + "scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.", + "loading": "Loading notifications…", + "error": "Notifications are temporarily unavailable.", + "retry": "Try again", + "preferences": "Notification preferences", + "preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.", + "support": "Support replies", + "friends": "Friend requests", + "events": "Upcoming events", + "unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}", + "saveError": "The change could not be saved. Please try again.", + "saved": "Change saved.", + "empty": "No notifications in your enabled categories.", + "read": "Read", + "unread": "Unread", + "markRead": "Mark as read", + "pagination": "Notification pages", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {pages}" } }, "emails": { diff --git a/src/messages/en.json b/src/messages/en.json index edc4bc7f..8a9c3f71 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -4826,6 +4826,30 @@ "previous": "Previous", "next": "Next", "pageSummary": "Page {page} of {pages} · {total} results" + }, + "notifications": { + "title": "Notifications", + "subtitle": "Your support replies, friend requests and registered events.", + "scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.", + "loading": "Loading notifications…", + "error": "Notifications are temporarily unavailable.", + "retry": "Try again", + "preferences": "Notification preferences", + "preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.", + "support": "Support replies", + "friends": "Friend requests", + "events": "Upcoming events", + "unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}", + "saveError": "The change could not be saved. Please try again.", + "saved": "Change saved.", + "empty": "No notifications in your enabled categories.", + "read": "Read", + "unread": "Unread", + "markRead": "Mark as read", + "pagination": "Notification pages", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {pages}" } }, "toolbar": { diff --git a/src/messages/es.json b/src/messages/es.json index afa74ac9..5c45f7b1 100644 --- a/src/messages/es.json +++ b/src/messages/es.json @@ -4816,6 +4816,30 @@ "previous": "Previous", "next": "Next", "pageSummary": "Page {page} of {pages} · {total} results" + }, + "notifications": { + "title": "Notifications", + "subtitle": "Your support replies, friend requests and registered events.", + "scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.", + "loading": "Loading notifications…", + "error": "Notifications are temporarily unavailable.", + "retry": "Try again", + "preferences": "Notification preferences", + "preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.", + "support": "Support replies", + "friends": "Friend requests", + "events": "Upcoming events", + "unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}", + "saveError": "The change could not be saved. Please try again.", + "saved": "Change saved.", + "empty": "No notifications in your enabled categories.", + "read": "Read", + "unread": "Unread", + "markRead": "Mark as read", + "pagination": "Notification pages", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {pages}" } }, "emails": { diff --git a/src/messages/fi.json b/src/messages/fi.json index e8a28b4b..5d741324 100644 --- a/src/messages/fi.json +++ b/src/messages/fi.json @@ -4820,6 +4820,30 @@ "previous": "Previous", "next": "Next", "pageSummary": "Page {page} of {pages} · {total} results" + }, + "notifications": { + "title": "Notifications", + "subtitle": "Your support replies, friend requests and registered events.", + "scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.", + "loading": "Loading notifications…", + "error": "Notifications are temporarily unavailable.", + "retry": "Try again", + "preferences": "Notification preferences", + "preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.", + "support": "Support replies", + "friends": "Friend requests", + "events": "Upcoming events", + "unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}", + "saveError": "The change could not be saved. Please try again.", + "saved": "Change saved.", + "empty": "No notifications in your enabled categories.", + "read": "Read", + "unread": "Unread", + "markRead": "Mark as read", + "pagination": "Notification pages", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {pages}" } }, "toolbar": { diff --git a/src/messages/fr.json b/src/messages/fr.json index 85762de6..5548e40f 100644 --- a/src/messages/fr.json +++ b/src/messages/fr.json @@ -4816,6 +4816,30 @@ "previous": "Previous", "next": "Next", "pageSummary": "Page {page} of {pages} · {total} results" + }, + "notifications": { + "title": "Notifications", + "subtitle": "Your support replies, friend requests and registered events.", + "scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.", + "loading": "Loading notifications…", + "error": "Notifications are temporarily unavailable.", + "retry": "Try again", + "preferences": "Notification preferences", + "preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.", + "support": "Support replies", + "friends": "Friend requests", + "events": "Upcoming events", + "unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}", + "saveError": "The change could not be saved. Please try again.", + "saved": "Change saved.", + "empty": "No notifications in your enabled categories.", + "read": "Read", + "unread": "Unread", + "markRead": "Mark as read", + "pagination": "Notification pages", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {pages}" } }, "emails": { diff --git a/src/messages/hr.json b/src/messages/hr.json index 08e30a6d..8fffffbe 100644 --- a/src/messages/hr.json +++ b/src/messages/hr.json @@ -4816,6 +4816,30 @@ "previous": "Previous", "next": "Next", "pageSummary": "Page {page} of {pages} · {total} results" + }, + "notifications": { + "title": "Notifications", + "subtitle": "Your support replies, friend requests and registered events.", + "scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.", + "loading": "Loading notifications…", + "error": "Notifications are temporarily unavailable.", + "retry": "Try again", + "preferences": "Notification preferences", + "preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.", + "support": "Support replies", + "friends": "Friend requests", + "events": "Upcoming events", + "unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}", + "saveError": "The change could not be saved. Please try again.", + "saved": "Change saved.", + "empty": "No notifications in your enabled categories.", + "read": "Read", + "unread": "Unread", + "markRead": "Mark as read", + "pagination": "Notification pages", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {pages}" } }, "emails": { diff --git a/src/messages/hu.json b/src/messages/hu.json index 93fcf99f..dda95239 100644 --- a/src/messages/hu.json +++ b/src/messages/hu.json @@ -4816,6 +4816,30 @@ "previous": "Previous", "next": "Next", "pageSummary": "Page {page} of {pages} · {total} results" + }, + "notifications": { + "title": "Notifications", + "subtitle": "Your support replies, friend requests and registered events.", + "scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.", + "loading": "Loading notifications…", + "error": "Notifications are temporarily unavailable.", + "retry": "Try again", + "preferences": "Notification preferences", + "preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.", + "support": "Support replies", + "friends": "Friend requests", + "events": "Upcoming events", + "unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}", + "saveError": "The change could not be saved. Please try again.", + "saved": "Change saved.", + "empty": "No notifications in your enabled categories.", + "read": "Read", + "unread": "Unread", + "markRead": "Mark as read", + "pagination": "Notification pages", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {pages}" } }, "emails": { diff --git a/src/messages/it.json b/src/messages/it.json index b2e4a33c..e577ca27 100644 --- a/src/messages/it.json +++ b/src/messages/it.json @@ -4825,6 +4825,30 @@ "previous": "Precedente", "next": "Successiva", "pageSummary": "Pagina {page} di {pages} · {total} risultati" + }, + "notifications": { + "title": "Notifiche", + "subtitle": "Le risposte dell’assistenza, le richieste di amicizia e gli eventi a cui sei iscritto.", + "scope": "Qui trovi fino a 100 risposte recenti dell’assistenza, 100 richieste di amicizia in sospeso e 100 eventi futuri a cui sei iscritto.", + "loading": "Caricamento notifiche…", + "error": "Le notifiche sono temporaneamente non disponibili.", + "retry": "Riprova", + "preferences": "Preferenze delle notifiche", + "preferencesHint": "Scegli le categorie da mostrare. Queste impostazioni non inviano email e non modificano le iscrizioni.", + "support": "Risposte dell’assistenza", + "friends": "Richieste di amicizia", + "events": "Prossimi eventi", + "unreadCount": "{count, plural, =0 {Nessuna notifica da leggere} one {# notifica da leggere} other {# notifiche da leggere}}", + "saveError": "Impossibile salvare la modifica. Riprova.", + "saved": "Modifica salvata.", + "empty": "Non ci sono notifiche nelle categorie abilitate.", + "read": "Letta", + "unread": "Da leggere", + "markRead": "Segna come letta", + "pagination": "Pagine delle notifiche", + "previous": "Precedente", + "next": "Successiva", + "page": "Pagina {page} di {pages}" } }, "toolbar": { diff --git a/src/messages/ja.json b/src/messages/ja.json index 74ba545b..898dc711 100644 --- a/src/messages/ja.json +++ b/src/messages/ja.json @@ -4820,6 +4820,30 @@ "previous": "Previous", "next": "Next", "pageSummary": "Page {page} of {pages} · {total} results" + }, + "notifications": { + "title": "Notifications", + "subtitle": "Your support replies, friend requests and registered events.", + "scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.", + "loading": "Loading notifications…", + "error": "Notifications are temporarily unavailable.", + "retry": "Try again", + "preferences": "Notification preferences", + "preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.", + "support": "Support replies", + "friends": "Friend requests", + "events": "Upcoming events", + "unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}", + "saveError": "The change could not be saved. Please try again.", + "saved": "Change saved.", + "empty": "No notifications in your enabled categories.", + "read": "Read", + "unread": "Unread", + "markRead": "Mark as read", + "pagination": "Notification pages", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {pages}" } }, "toolbar": { diff --git a/src/messages/nl.json b/src/messages/nl.json index 9de72b20..1afe4526 100644 --- a/src/messages/nl.json +++ b/src/messages/nl.json @@ -4826,6 +4826,30 @@ "description": "Try again later or contact an administrator." } } + }, + "notifications": { + "title": "Meldingen", + "subtitle": "Je supportreacties, vriendschapsverzoeken en evenementen waarvoor je bent ingeschreven.", + "scope": "Hier zie je maximaal 100 recente supportreacties, 100 openstaande vriendschapsverzoeken en 100 komende evenementen waarvoor je bent ingeschreven.", + "loading": "Meldingen laden…", + "error": "Meldingen zijn tijdelijk niet beschikbaar.", + "retry": "Opnieuw proberen", + "preferences": "Meldingsvoorkeuren", + "preferencesHint": "Kies welke categorieën hier verschijnen. Deze instellingen versturen geen e-mails en wijzigen je inschrijvingen niet.", + "support": "Supportreacties", + "friends": "Vriendschapsverzoeken", + "events": "Komende evenementen", + "unreadCount": "{count, plural, =0 {Geen ongelezen meldingen} one {# ongelezen melding} other {# ongelezen meldingen}}", + "saveError": "De wijziging kon niet worden opgeslagen. Probeer het opnieuw.", + "saved": "Wijziging opgeslagen.", + "empty": "Geen meldingen in je ingeschakelde categorieën.", + "read": "Gelezen", + "unread": "Ongelezen", + "markRead": "Markeren als gelezen", + "pagination": "Meldingspagina’s", + "previous": "Vorige", + "next": "Volgende", + "page": "Pagina {page} van {pages}" } }, "toolbar": { diff --git a/src/messages/no.json b/src/messages/no.json index a07290e0..0402a7d0 100644 --- a/src/messages/no.json +++ b/src/messages/no.json @@ -4816,6 +4816,30 @@ "previous": "Previous", "next": "Next", "pageSummary": "Page {page} of {pages} · {total} results" + }, + "notifications": { + "title": "Notifications", + "subtitle": "Your support replies, friend requests and registered events.", + "scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.", + "loading": "Loading notifications…", + "error": "Notifications are temporarily unavailable.", + "retry": "Try again", + "preferences": "Notification preferences", + "preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.", + "support": "Support replies", + "friends": "Friend requests", + "events": "Upcoming events", + "unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}", + "saveError": "The change could not be saved. Please try again.", + "saved": "Change saved.", + "empty": "No notifications in your enabled categories.", + "read": "Read", + "unread": "Unread", + "markRead": "Mark as read", + "pagination": "Notification pages", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {pages}" } }, "emails": { diff --git a/src/messages/pl.json b/src/messages/pl.json index a393c15a..18be916c 100644 --- a/src/messages/pl.json +++ b/src/messages/pl.json @@ -4816,6 +4816,30 @@ "previous": "Previous", "next": "Next", "pageSummary": "Page {page} of {pages} · {total} results" + }, + "notifications": { + "title": "Notifications", + "subtitle": "Your support replies, friend requests and registered events.", + "scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.", + "loading": "Loading notifications…", + "error": "Notifications are temporarily unavailable.", + "retry": "Try again", + "preferences": "Notification preferences", + "preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.", + "support": "Support replies", + "friends": "Friend requests", + "events": "Upcoming events", + "unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}", + "saveError": "The change could not be saved. Please try again.", + "saved": "Change saved.", + "empty": "No notifications in your enabled categories.", + "read": "Read", + "unread": "Unread", + "markRead": "Mark as read", + "pagination": "Notification pages", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {pages}" } }, "emails": { diff --git a/src/messages/pt.json b/src/messages/pt.json index 0c1c5470..1d7d3888 100644 --- a/src/messages/pt.json +++ b/src/messages/pt.json @@ -4816,6 +4816,30 @@ "previous": "Previous", "next": "Next", "pageSummary": "Page {page} of {pages} · {total} results" + }, + "notifications": { + "title": "Notifications", + "subtitle": "Your support replies, friend requests and registered events.", + "scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.", + "loading": "Loading notifications…", + "error": "Notifications are temporarily unavailable.", + "retry": "Try again", + "preferences": "Notification preferences", + "preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.", + "support": "Support replies", + "friends": "Friend requests", + "events": "Upcoming events", + "unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}", + "saveError": "The change could not be saved. Please try again.", + "saved": "Change saved.", + "empty": "No notifications in your enabled categories.", + "read": "Read", + "unread": "Unread", + "markRead": "Mark as read", + "pagination": "Notification pages", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {pages}" } }, "emails": { diff --git a/src/messages/ro.json b/src/messages/ro.json index bbc4bcbd..a113ba9d 100644 --- a/src/messages/ro.json +++ b/src/messages/ro.json @@ -4816,6 +4816,30 @@ "previous": "Previous", "next": "Next", "pageSummary": "Page {page} of {pages} · {total} results" + }, + "notifications": { + "title": "Notifications", + "subtitle": "Your support replies, friend requests and registered events.", + "scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.", + "loading": "Loading notifications…", + "error": "Notifications are temporarily unavailable.", + "retry": "Try again", + "preferences": "Notification preferences", + "preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.", + "support": "Support replies", + "friends": "Friend requests", + "events": "Upcoming events", + "unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}", + "saveError": "The change could not be saved. Please try again.", + "saved": "Change saved.", + "empty": "No notifications in your enabled categories.", + "read": "Read", + "unread": "Unread", + "markRead": "Mark as read", + "pagination": "Notification pages", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {pages}" } }, "emails": { diff --git a/src/messages/ru.json b/src/messages/ru.json index 955df810..cf76f727 100644 --- a/src/messages/ru.json +++ b/src/messages/ru.json @@ -4814,6 +4814,30 @@ "previous": "Previous", "next": "Next", "pageSummary": "Page {page} of {pages} · {total} results" + }, + "notifications": { + "title": "Notifications", + "subtitle": "Your support replies, friend requests and registered events.", + "scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.", + "loading": "Loading notifications…", + "error": "Notifications are temporarily unavailable.", + "retry": "Try again", + "preferences": "Notification preferences", + "preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.", + "support": "Support replies", + "friends": "Friend requests", + "events": "Upcoming events", + "unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}", + "saveError": "The change could not be saved. Please try again.", + "saved": "Change saved.", + "empty": "No notifications in your enabled categories.", + "read": "Read", + "unread": "Unread", + "markRead": "Mark as read", + "pagination": "Notification pages", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {pages}" } }, "emails": { diff --git a/src/messages/sk.json b/src/messages/sk.json index 9e7d8278..51459194 100644 --- a/src/messages/sk.json +++ b/src/messages/sk.json @@ -4816,6 +4816,30 @@ "previous": "Previous", "next": "Next", "pageSummary": "Page {page} of {pages} · {total} results" + }, + "notifications": { + "title": "Notifications", + "subtitle": "Your support replies, friend requests and registered events.", + "scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.", + "loading": "Loading notifications…", + "error": "Notifications are temporarily unavailable.", + "retry": "Try again", + "preferences": "Notification preferences", + "preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.", + "support": "Support replies", + "friends": "Friend requests", + "events": "Upcoming events", + "unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}", + "saveError": "The change could not be saved. Please try again.", + "saved": "Change saved.", + "empty": "No notifications in your enabled categories.", + "read": "Read", + "unread": "Unread", + "markRead": "Mark as read", + "pagination": "Notification pages", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {pages}" } }, "emails": { diff --git a/src/messages/sr.json b/src/messages/sr.json index 988fbeec..389f4d0d 100644 --- a/src/messages/sr.json +++ b/src/messages/sr.json @@ -4816,6 +4816,30 @@ "previous": "Previous", "next": "Next", "pageSummary": "Page {page} of {pages} · {total} results" + }, + "notifications": { + "title": "Notifications", + "subtitle": "Your support replies, friend requests and registered events.", + "scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.", + "loading": "Loading notifications…", + "error": "Notifications are temporarily unavailable.", + "retry": "Try again", + "preferences": "Notification preferences", + "preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.", + "support": "Support replies", + "friends": "Friend requests", + "events": "Upcoming events", + "unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}", + "saveError": "The change could not be saved. Please try again.", + "saved": "Change saved.", + "empty": "No notifications in your enabled categories.", + "read": "Read", + "unread": "Unread", + "markRead": "Mark as read", + "pagination": "Notification pages", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {pages}" } }, "emails": { diff --git a/src/messages/sv.json b/src/messages/sv.json index 72f31649..99833a0e 100644 --- a/src/messages/sv.json +++ b/src/messages/sv.json @@ -4816,6 +4816,30 @@ "previous": "Previous", "next": "Next", "pageSummary": "Page {page} of {pages} · {total} results" + }, + "notifications": { + "title": "Notifications", + "subtitle": "Your support replies, friend requests and registered events.", + "scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.", + "loading": "Loading notifications…", + "error": "Notifications are temporarily unavailable.", + "retry": "Try again", + "preferences": "Notification preferences", + "preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.", + "support": "Support replies", + "friends": "Friend requests", + "events": "Upcoming events", + "unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}", + "saveError": "The change could not be saved. Please try again.", + "saved": "Change saved.", + "empty": "No notifications in your enabled categories.", + "read": "Read", + "unread": "Unread", + "markRead": "Mark as read", + "pagination": "Notification pages", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {pages}" } }, "emails": { diff --git a/src/messages/tr.json b/src/messages/tr.json index 742d9b38..f235ffa0 100644 --- a/src/messages/tr.json +++ b/src/messages/tr.json @@ -4816,6 +4816,30 @@ "previous": "Previous", "next": "Next", "pageSummary": "Page {page} of {pages} · {total} results" + }, + "notifications": { + "title": "Notifications", + "subtitle": "Your support replies, friend requests and registered events.", + "scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.", + "loading": "Loading notifications…", + "error": "Notifications are temporarily unavailable.", + "retry": "Try again", + "preferences": "Notification preferences", + "preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.", + "support": "Support replies", + "friends": "Friend requests", + "events": "Upcoming events", + "unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}", + "saveError": "The change could not be saved. Please try again.", + "saved": "Change saved.", + "empty": "No notifications in your enabled categories.", + "read": "Read", + "unread": "Unread", + "markRead": "Mark as read", + "pagination": "Notification pages", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {pages}" } }, "emails": { diff --git a/src/messages/uk.json b/src/messages/uk.json index b276e631..4bbfa37e 100644 --- a/src/messages/uk.json +++ b/src/messages/uk.json @@ -4816,6 +4816,30 @@ "previous": "Previous", "next": "Next", "pageSummary": "Page {page} of {pages} · {total} results" + }, + "notifications": { + "title": "Notifications", + "subtitle": "Your support replies, friend requests and registered events.", + "scope": "This inbox shows up to 100 recent support replies, 100 pending friend requests and 100 upcoming registered events.", + "loading": "Loading notifications…", + "error": "Notifications are temporarily unavailable.", + "retry": "Try again", + "preferences": "Notification preferences", + "preferencesHint": "Choose which categories appear here. These settings do not send emails or change your registrations.", + "support": "Support replies", + "friends": "Friend requests", + "events": "Upcoming events", + "unreadCount": "{count, plural, =0 {No unread notifications} one {# unread notification} other {# unread notifications}}", + "saveError": "The change could not be saved. Please try again.", + "saved": "Change saved.", + "empty": "No notifications in your enabled categories.", + "read": "Read", + "unread": "Unread", + "markRead": "Mark as read", + "pagination": "Notification pages", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {pages}" } }, "emails": {