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

+1
View File
@@ -197,6 +197,7 @@ const server = createServer(async (req, res) => {
}
if (
[
"/notifications",
"/admin/unsaved-harness",
"/admin/attachment-harness",
"/admin/studio-harness",
+21 -3
View File
@@ -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}
>
<main data-admin className="min-w-0 mx-auto max-w-6xl p-5">
<h1 className="mb-5 text-2xl font-bold">
<main
data-admin={route.includes("notifications") ? undefined : ""}
className={
route.includes("notifications")
? "min-w-0 mx-auto max-w-7xl px-3 py-3 md:px-6 md:py-6"
: "min-w-0 mx-auto max-w-6xl p-5"
}
>
<h1
hidden={route.includes("notifications")}
className="mb-5 text-2xl font-bold"
>
{route.includes("settings")
? "CMS settings"
: route.includes("tickets")
@@ -106,7 +122,9 @@ createRoot(root).render(
? "News editor"
: "Furniture synchronization"}
</h1>
{route.includes("unsaved-harness") ? (
{route.includes("notifications") ? (
<NotificationCenter userId={7} />
) : route.includes("unsaved-harness") ? (
<UnsavedHarness />
) : route.includes("studio-harness") ? (
<StudioClient
+162
View File
@@ -0,0 +1,162 @@
import { expect, test } from "@playwright/test";
test.beforeEach(async ({ page }) => {
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<string>();
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();
});