Merge branch 'main' of https://gitlab.epicnabbo.nl/remco/epicnext-cms
CI / check (push) Failing after 1m5s
CI / deploy (push) Skipped

This commit is contained in:
openhands committed 2026-09-06 19:21:47 +02:00
commit fea8023ac6
87 files changed
+3760 -1105

No files matched your search

+13 -1
View File
@@ -212,8 +212,13 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
icon: Server,
prefixes: ["/admin/devops"],
tabs: [
{ href: "/admin/devops", labelKey: "devops", match: ["/admin/devops"] },
{
href: "/admin/devops",
labelKey: "devops",
match: ["/admin/devops"],
},
{ href: "/admin/devops/errors", labelKey: "errors" },
{ href: "/admin/devops/cms-errors", labelKey: "cmsErrors" },
],
},
];
@@ -665,6 +670,13 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
icon: Server,
permission: PERMS.DEVOPS_VIEW,
matchPrefixes: ["/admin/devops"],
matchExcludePrefixes: ["/admin/devops/cms-errors"],
},
{
href: "/admin/devops/cms-errors",
labelKey: "cmsErrors",
icon: AlertTriangle,
permission: PERMS.DEVOPS_VIEW,
},
{
href: "/admin/alerts",
+38
View File
@@ -0,0 +1,38 @@
import { expect, it } from "vitest";
import { ADMIN_NAV_GROUPS } from "@/lib/admin-nav";
import { visibleAdminNavigation } from "./admin-navigation";
const access = { isSuperAdmin: true, allowedPermissions: [], navConfig: {} };
it("retains each route and its permission definition once after regrouping", () => {
const original = ADMIN_NAV_GROUPS.flatMap((g) => g.items);
const actual = visibleAdminNavigation(access).flatMap((g) => g.items);
expect(actual).toHaveLength(original.length);
expect(new Set(actual.map((i) => i.href)).size).toBe(actual.length);
for (const item of original) expect(actual).toContain(item);
expect(
visibleAdminNavigation(access)
.find((g) => g.labelKey === "catalogFurniture")
?.items.some((i) => i.href === "/admin/studio"),
).toBe(true);
});
it("respects hidden groups, owner order and ACL in every consumer", () => {
const configured = visibleAdminNavigation({
...access,
navConfig: {
groupOrder: ["monitoring"],
hiddenGroups: ["economy"],
hiddenItems: ["/admin/studio"],
},
});
expect(configured[0].labelKey).toBe("monitoring");
expect(
configured
.flatMap((g) => g.items)
.some((i) => i.href === "/admin/studio" || i.href === "/admin/catalog"),
).toBe(false);
const limited = visibleAdminNavigation({
...access,
isSuperAdmin: false,
}).flatMap((g) => g.items);
expect(limited.every((i) => !i.permission)).toBe(true);
});
+161
View File
@@ -0,0 +1,161 @@
import {
Activity,
Cog,
LayoutDashboard,
Newspaper,
Package,
ShoppingCart,
Users,
UsersRound,
} from "lucide-react";
import {
ADMIN_NAV_GROUPS,
type AdminNavGroup,
navItemIsAllowed,
} from "@/lib/admin-nav";
import {
type AdminNavConfig,
applyAdminNavConfig,
} from "@/lib/admin-nav-config";
export type AdminNavigationOptions = {
allowedPermissions: string[];
isSuperAdmin: boolean;
navConfig: AdminNavConfig;
};
const sections = [
{ labelKey: "overview", icon: LayoutDashboard, roots: [""] },
{
labelKey: "peopleSupport",
icon: Users,
roots: [
"users",
"online",
"tickets",
"help-tickets",
"bans",
"ip",
"vpn",
"wordfilter",
"moderation",
"rooms",
],
},
{
labelKey: "catalogFurniture",
icon: Package,
roots: [
"studio",
"catalog",
"import",
"items",
"rare-values",
"badges",
"achievements",
"sounds",
],
},
{
labelKey: "communityContent",
icon: Newspaper,
roots: [
"articles",
"photos",
"events",
"polls",
"guilds",
"media",
"radio",
"ads",
"banners",
"navigation",
"help-questions",
"writeable-boxes",
"tags",
"prefixes",
],
},
{
labelKey: "economy",
icon: ShoppingCart,
roots: [
"shop",
"marketplace",
"transactions",
"vouchers",
"subscriptions",
"calendar",
],
},
{
labelKey: "staffPermissions",
icon: UsersRound,
roots: ["teams", "applications", "permissions"],
},
{
labelKey: "system",
icon: Cog,
roots: [
"settings",
"theme",
"theme-builder",
"maintenance",
"favicon",
"email-templates",
"translations",
"emulator",
"housekeeping",
"commandocentrum",
],
},
{
labelKey: "monitoring",
icon: Activity,
roots: ["logs", "analytics", "devops", "alerts"],
},
];
/** Owner order takes precedence; hidden entries are filtered before regrouping. */
export function visibleAdminNavigation(
options: AdminNavigationOptions,
): AdminNavGroup[] {
const allowed = new Set(options.allowedPermissions);
const groups = applyAdminNavConfig(ADMIN_NAV_GROUPS, options.navConfig)
.map((group) => ({
...group,
items: group.items.filter((item) =>
navItemIsAllowed(item, {
isSuperAdmin: options.isSuperAdmin,
has: (slug) => allowed.has(slug),
}),
),
}))
.filter((group) => group.items.length);
if (
options.navConfig.groupOrder?.length ||
Object.values(options.navConfig.itemOrder ?? {}).some(
(order) => order.length,
)
)
return groups;
const remaining = new Map(
groups.flatMap((group) => group.items).map((item) => [item.href, item]),
);
const organized = sections
.map((section) => {
const items = [...remaining.values()].filter((item) =>
section.roots.includes(item.href.split("/")[2] ?? ""),
);
for (const item of items) remaining.delete(item.href);
return { labelKey: section.labelKey, icon: section.icon, items };
})
.filter((group) => group.items.length);
// New routes remain discoverable even before being assigned to an activity.
if (remaining.size)
organized.push({
labelKey: "tools",
icon: Cog,
items: [...remaining.values()],
});
return organized;
}
+89
View File
@@ -0,0 +1,89 @@
import { beforeEach, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
allowed: new Set<string>(),
queried: [] as unknown[],
}));
vi.mock("@/lib/api-handler", () => ({
withAdmin: (_: unknown, handler: Function) => (request: unknown) =>
handler(request, {
session: { user: { id: 1, rank: 7 } },
permissions: { isSuperAdmin: false },
}),
}));
vi.mock("@/lib/rate-limit", () => ({ rateLimit: async () => ({ ok: true }) }));
vi.mock("@/lib/permissions", async () => ({
...(await import("@/lib/permission-slugs")),
canAccess: (_: unknown, slug: string) => state.allowed.has(slug),
}));
vi.mock("@/lib/db", () => {
const tables = Object.fromEntries(
[
"User",
"Rooms",
"Guilds",
"WebsiteArticles",
"WebsiteRareValues",
"WebsiteShopArticles",
].map((name) => [
name,
{
name,
id: `${name}Id`,
username: `${name}Name`,
mail: `${name}Mail`,
slug: `${name}Slug`,
ownerName: `${name}Owner`,
itemId: `${name}Item`,
description: `${name}Description`,
},
]),
);
return {
...tables,
db: {
select() {
let table: unknown;
const query = {
from(value: unknown) {
table = value;
return query;
},
where() {
return query;
},
async limit() {
state.queried.push(table);
return [{ id: 1, title: "Match", subtitle: "Data" }];
},
};
return query;
},
},
};
});
import { GET } from "@/app/api/admin/search/route";
import { PERMS } from "@/lib/permission-slugs";
beforeEach(() => {
state.allowed.clear();
state.queried.length = 0;
});
const request = () =>
({
nextUrl: new URL("https://test.invalid/api/admin/search?q=match"),
}) as Parameters<typeof GET>[0];
it("does not query or expose categories without their view permission", async () => {
const response = await GET(request(), {});
expect((await response.json()).results).toEqual([]);
expect(state.queried).toHaveLength(0);
});
it("searches only the permitted category", async () => {
state.allowed.add(PERMS.NEWS_VIEW);
const response = await GET(request(), {});
const body = await response.json();
expect(body.results).toHaveLength(1);
expect(body.results[0].type).toBe("articles");
expect(state.queried).toHaveLength(1);
});
+17
View File
@@ -0,0 +1,17 @@
import { expect, it } from "vitest";
import { tablePageNumbers } from "./admin-table-state";
it.each([
[1, 1],
[1, 7],
[6, 7],
[7, 7],
[50, 100],
[100, 100],
])("includes the current page %i/%i and stays in bounds", (page, last) => {
const pages = tablePageNumbers(page, last);
expect(pages).toContain(page);
expect(pages.length).toBeLessThanOrEqual(5);
expect(pages.every((p) => p >= 1 && p <= last)).toBe(true);
expect(new Set(pages).size).toBe(pages.length);
});
+6
View File
@@ -0,0 +1,6 @@
/** A centered, bounded window including the current page, also at either end. */
export function tablePageNumbers(page: number, last: number): number[] {
const count = Math.min(5, Math.max(1, last));
const start = Math.max(1, Math.min(page - 2, last - count + 1));
return Array.from({ length: count }, (_, index) => start + index);
}
+32
View File
@@ -0,0 +1,32 @@
import { cache } from "react";
import { collectNavPermissionSlugs } from "@/lib/admin-nav";
import {
ADMIN_NAV_CONFIG_KEY,
parseAdminNavConfig,
} from "@/lib/admin-nav-config";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { siteSettings } from "@/lib/services/site-settings";
export const loadAdminNavigation = cache(async () => {
const [{ session, permissions }, raw] = await Promise.all([
getAdminContext(),
siteSettings.get(ADMIN_NAV_CONFIG_KEY, ""),
]);
const hasDashboard = canAccess(
permissions,
PERMS.ADMIN_DASHBOARD,
session.user.rank,
);
return {
isSuperAdmin: permissions.isSuperAdmin,
allowedPermissions: permissions.isSuperAdmin
? []
: collectNavPermissionSlugs().filter(
(slug) =>
canAccess(permissions, slug, session.user.rank) ||
(hasDashboard &&
(slug.endsWith(".view") || slug === PERMS.ADMIN_DASHBOARD)),
),
navConfig: parseAdminNavConfig(raw),
};
});
+27
View File
@@ -0,0 +1,27 @@
import { readFileSync } from "node:fs";
import { expect, it } from "vitest";
import { articleSlug, readArticleInput } from "./article-input";
it("normalizes Next catch-all slug parameters", () => {
expect(articleSlug(["welcome"])).toBe("welcome");
expect(articleSlug("welcome")).toBe("welcome");
});
it("keeps the selected timezone instant when scheduling", () => {
const form = new FormData();
form.set("title", "News");
form.set("status", "scheduled");
form.set("publishAt", "2026-09-06T20:00:00+02:00");
expect(readArticleInput(form).publishAt?.toISOString()).toBe(
"2026-09-06T18:00:00.000Z",
);
});
it("ships additive publication columns for existing installations", () => {
const sql = readFileSync(
"drizzle/migrations/0025_article_publication.sql",
"utf8",
);
for (const column of ["status", "publish_at", "published_at"])
expect(sql).toContain(`ADD COLUMN IF NOT EXISTS ${column}`);
expect(sql).toContain("DEFAULT 'published'");
expect(sql).not.toMatch(/DROP|UPDATE\s+website_articles/i);
});
+47
View File
@@ -0,0 +1,47 @@
import type { ActionResult } from "./safe-action-shared";
export type ArticleSaveResult = ActionResult<{ redirectTo: string }>;
export class ArticleInputError extends Error {
constructor(
public readonly code:
| "titleRequired"
| "titleTooLong"
| "summaryTooLong"
| "imageTooLong"
| "invalidStatus"
| "publishDateRequired",
) {
super(code);
}
}
export function readArticleInput(form: FormData) {
const text = (key: string) =>
String(form.get(key) ?? "")
.normalize("NFC")
.trim();
const title = text("title"),
shortStory = text("shortStory"),
fullStory = text("fullStory"),
image = text("image");
if (!title) throw new ArticleInputError("titleRequired");
if (title.length > 255) throw new ArticleInputError("titleTooLong");
if (shortStory.length > 255) throw new ArticleInputError("summaryTooLong");
if (image.length > 255) throw new ArticleInputError("imageTooLong");
const status = text("status") || "published";
if (status !== "published" && status !== "draft" && status !== "scheduled")
throw new ArticleInputError("invalidStatus");
const publishAt = status === "scheduled" ? new Date(text("publishAt")) : null;
if (publishAt && Number.isNaN(publishAt.getTime()))
throw new ArticleInputError("publishDateRequired");
return {
title,
shortStory,
fullStory,
image,
status,
publishAt,
rawSlug: text("slug"),
};
}
export function articleSlug(value: string | string[]): string {
return Array.isArray(value) ? value.join("/") : value;
}
+36
View File
@@ -0,0 +1,36 @@
import {
type MessageFormatElement,
parse,
TYPE,
} from "@formatjs/icu-messageformat-parser";
/** Argument names and rich-text tags are a contract with the calling component. */
export function messageVariables(value: string): string[] {
const names = new Set<string>();
function visit(nodes: MessageFormatElement[]) {
for (const node of nodes) {
if (node.type !== TYPE.literal && node.type !== TYPE.pound)
names.add(node.value);
if (node.type === TYPE.select || node.type === TYPE.plural) {
for (const option of Object.values(node.options)) visit(option.value);
}
if (node.type === TYPE.tag) visit(node.children);
}
}
visit(parse(value));
return [...names].sort();
}
export function validateCmsMessage(
value: string,
source: string,
): "empty" | "invalid" | "variables" | null {
if (!value.trim()) return "empty";
try {
return messageVariables(value).join("\0") ===
messageVariables(source).join("\0")
? null
: "variables";
} catch {
return "invalid";
}
}
+108
View File
@@ -0,0 +1,108 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { readCmsTranslation, saveCmsTranslation } from "./cms-translations";
const directories: string[] = [];
afterEach(async () => {
await Promise.all(
directories
.splice(0)
.map((dir) => rm(dir, { recursive: true, force: true })),
);
});
async function root() {
const dir = await mkdtemp(path.join(os.tmpdir(), "cms-translations-test-"));
directories.push(dir);
return dir;
}
describe("CMS translation persistence", () => {
it("reads bundled messages without source files and persists only overrides", async () => {
const dir = await root();
const before = await readCmsTranslation("it", dir);
expect(before.messages["pages.admin.search.users"]).toBe("Utenti");
await saveCmsTranslation(
"it",
before.revision,
{ "pages.admin.search.users": "Persone" },
dir,
);
expect(
(await readCmsTranslation("it", dir)).messages[
"pages.admin.search.users"
],
).toBe("Persone");
expect(
JSON.parse(await readFile(path.join(dir, "it.json"), "utf8")),
).toEqual({ "pages.admin.search.users": "Persone" });
});
it("rejects stale saves and invalid ICU without overwriting saved text", async () => {
const dir = await root();
const before = await readCmsTranslation("it", dir);
await saveCmsTranslation(
"it",
before.revision,
{ "pages.admin.search.users": "Persone" },
dir,
);
await expect(
saveCmsTranslation(
"it",
before.revision,
{ "pages.admin.search.users": "Stale" },
dir,
),
).rejects.toThrow("conflict");
const current = await readCmsTranslation("it", dir);
await expect(
saveCmsTranslation(
"it",
current.revision,
{ "pages.admin.dashboard.totalAccounts": "{broken" },
dir,
),
).rejects.toThrow("invalid");
await expect(
saveCmsTranslation(
"it",
current.revision,
{ "pages.admin.dashboard.totalAccounts": "No variable" },
dir,
),
).rejects.toThrow("variables");
expect((await readCmsTranslation("it", dir)).revision).toBe(
current.revision,
);
});
it("restores the bundled value when an override is reset", async () => {
const dir = await root();
const before = await readCmsTranslation("it", dir);
const saved = await saveCmsTranslation(
"it",
before.revision,
{ "pages.admin.search.users": "Persone" },
dir,
);
const reset = await saveCmsTranslation(
"it",
saved.revision,
{ "pages.admin.search.users": null },
dir,
);
expect(reset.messages["pages.admin.search.users"]).toBe("Utenti");
});
it("rejects unknown message keys and does not mutate prototypes", async () => {
const dir = await root();
const before = await readCmsTranslation("it", dir);
await expect(
saveCmsTranslation(
"it",
before.revision,
{ "__proto__.polluted": "yes" },
dir,
),
).rejects.toThrow("unknownKey");
expect(Object.prototype).not.toHaveProperty("polluted");
});
});
+133
View File
@@ -0,0 +1,133 @@
import "server-only";
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { cache } from "react";
import { type AppLocale, isSupportedLocale } from "@/i18n/locales";
import type { MessageTree } from "@/i18n/merge-messages";
import { validateCmsMessage } from "./cms-message-validation";
import { logger } from "./logger";
import {
withGamedataLock,
writeGamedataJsonAtomic,
} from "./services/import/core/gamedata-json";
import { flattenObject, unflattenObject } from "./translations-utils";
const defaultRoot = path.join(
/*turbopackIgnore: true*/ process.cwd(),
"storage",
"cms-translations",
);
export interface CmsTranslationSnapshot {
messages: Record<string, string>;
revision: string;
}
export class CmsTranslationError extends Error {
constructor(
public readonly code:
| "conflict"
| "unknownKey"
| "empty"
| "invalid"
| "variables",
public readonly key = "",
) {
super(`${code}: ${key}`);
}
}
async function bundled(locale: AppLocale): Promise<Record<string, string>> {
return flattenObject((await import(`../messages/${locale}.json`)).default);
}
async function readOverrides(file: string): Promise<Record<string, string>> {
try {
const parsed: unknown = JSON.parse(
await fs.readFile(/*turbopackIgnore: true*/ file, "utf8"),
);
if (
!parsed ||
typeof parsed !== "object" ||
Array.isArray(parsed) ||
Object.values(parsed).some((v) => typeof v !== "string")
)
throw new Error("Invalid CMS translation override file");
return parsed as Record<string, string>;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
throw error;
}
}
function localeFile(locale: AppLocale, root: string) {
if (!isSupportedLocale(locale)) throw new Error("Unsupported locale");
return path.join(root, `${locale}.json`);
}
export async function readCmsTranslation(
locale: AppLocale,
root = defaultRoot,
): Promise<CmsTranslationSnapshot> {
const [base, source, overrides] = await Promise.all([
bundled(locale),
bundled("en"),
readOverrides(localeFile(locale, root)),
]);
const messages = { ...base };
for (const [key, value] of Object.entries(overrides)) {
// Obsolete or incompatible overrides must not break new releases.
if (!Object.hasOwn(source, key) || validateCmsMessage(value, source[key]))
continue;
messages[key] = value;
}
return {
messages,
revision: createHash("sha256")
.update(JSON.stringify([base, overrides]))
.digest("hex"),
};
}
export async function saveCmsTranslation(
locale: AppLocale,
revision: string,
changes: Record<string, string | null>,
root = defaultRoot,
): Promise<CmsTranslationSnapshot> {
const file = localeFile(locale, root);
await fs.mkdir(root, { recursive: true });
return withGamedataLock(file, async () => {
const current = await readCmsTranslation(locale, root);
if (current.revision !== revision)
throw new CmsTranslationError("conflict");
const [base, source, overrides] = await Promise.all([
bundled(locale),
bundled("en"),
readOverrides(file),
]);
for (const [key, value] of Object.entries(changes)) {
if (!Object.hasOwn(source, key))
throw new CmsTranslationError("unknownKey", key);
if (value === null || value === base[key]) {
delete overrides[key];
continue;
}
const issue = validateCmsMessage(value, source[key]);
if (issue) throw new CmsTranslationError(issue, key);
overrides[key] = value;
}
await writeGamedataJsonAtomic(file, overrides);
return readCmsTranslation(locale, root);
});
}
/** Per-request deduplication; runtime edits are visible on the next request. */
export const loadCmsMessages = cache(
async (locale: AppLocale): Promise<MessageTree> => {
try {
return unflattenObject(
(await readCmsTranslation(locale)).messages,
) as MessageTree;
} catch (error) {
logger.error("CMS translation overrides could not be loaded", {
locale,
error,
});
return (await import(`../messages/${locale}.json`)).default;
}
},
);
+25
View File
@@ -0,0 +1,25 @@
import { expect, it } from "vitest";
import type { ImportJob } from "./import-job";
import { retryableJobItems } from "./import-job-retry";
const job = (state: ImportJob["state"]) =>
({
state,
items: ["done", "failed", "pending", "running"].map((state) => ({
state,
classname: state,
})),
}) as ImportJob;
it("only retries failures after a completed job", () =>
expect(retryableJobItems(job("completed")).map((i) => i.state)).toEqual([
"failed",
]));
it("recovers all unfinished items from an interrupted job", () =>
expect(retryableJobItems(job("interrupted")).map((i) => i.state)).toEqual([
"failed",
"pending",
"running",
]));
it.each(["queued", "running"] as const)("never duplicates a %s job", (state) =>
expect(retryableJobItems(job(state))).toEqual([]),
);
+10
View File
@@ -0,0 +1,10 @@
import type { ImportJob } from "./import-job";
/** Never requeue an active job or furniture already imported successfully. */
export function retryableJobItems(job: ImportJob): ImportJob["items"] {
if (job.state === "running" || job.state === "queued") return [];
return job.items.filter(
(item) =>
item.state === "failed" ||
(job.state === "interrupted" && item.state !== "done"),
);
}
+67
View File
@@ -0,0 +1,67 @@
import { beforeEach, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
revision: "0",
values: new Map<string, unknown>(),
get: vi.fn(),
set: vi.fn(),
status: "ready",
}));
vi.mock("@/lib/redis", () => ({ redis: state }));
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
vi.mock("@/lib/cache", () => ({
cached: async (key: string, _ttl: number, query: () => Promise<unknown>) => {
if (state.values.has(key)) return state.values.get(key);
const result = await query();
state.values.set(key, result);
return result;
},
}));
import { cacheNews, invalidateNewsCache } from "./news-cache";
beforeEach(() => {
state.values.clear();
state.revision = "0";
state.status = "ready";
state.get.mockReset().mockImplementation(async () => state.revision);
state.set
.mockReset()
.mockImplementation(async (_key: string, value: string) => {
state.revision = value;
});
});
it("invalidates a previously cached public list", async () => {
const query = vi
.fn()
.mockResolvedValueOnce([])
.mockResolvedValueOnce(["new article"]);
expect(await cacheNews("list", 60000, query)).toEqual([]);
await invalidateNewsCache();
expect(await cacheNews("list", 60000, query)).toEqual(["new article"]);
});
it("does not let a stale in-flight read replace a newer revision", async () => {
let finish!: (value: string[]) => void;
const old = cacheNews(
"list",
60000,
() =>
new Promise<string[]>((resolve) => {
finish = resolve;
}),
);
await vi.waitFor(() => expect(finish).toBeDefined());
await invalidateNewsCache();
expect(await cacheNews("list", 60000, async () => ["new"])).toEqual(["new"]);
finish([]);
await old;
expect(await cacheNews("list", 60000, async () => ["wrong"])).toEqual([
"new",
]);
});
it("reads fresh data when Redis is unavailable", async () => {
state.get.mockRejectedValue(Error("offline"));
expect(await cacheNews("list", 60000, async () => ["fresh"])).toEqual([
"fresh",
]);
});
+32
View File
@@ -0,0 +1,32 @@
import "server-only";
import { randomUUID } from "node:crypto";
import { cached } from "@/lib/cache";
import { logger } from "@/lib/logger";
import { redis } from "@/lib/redis";
const REVISION_KEY = "cms:news:revision";
export async function cacheNews<T>(
key: string,
ttlMs: number,
query: () => Promise<T>,
): Promise<T> {
if (!redis || redis.status === "end") return query();
let revision: string;
try {
revision = (await redis.get(REVISION_KEY)) ?? "0";
} catch {
return query();
}
return cached(`news:${revision}:${key}`, ttlMs, query);
}
export async function invalidateNewsCache(): Promise<void> {
if (!redis || redis.status === "end") return;
try {
await redis.set(REVISION_KEY, randomUUID());
} catch (error) {
logger.error("News saved but public cache invalidation failed", {
module: "news",
error,
});
}
}
+5 -3
View File
@@ -1,7 +1,8 @@
import { logger } from "@/lib/logger";
import { cacheNews } from "@/lib/services/news-cache";
import "server-only";
import { and, desc, eq, or, sql } from "drizzle-orm";
import { cached } from "@/lib/cache";
import { db, WebsiteArticles } from "@/lib/db";
export interface NewsListItem {
@@ -32,7 +33,7 @@ const FETCH_LIMIT = 30;
*/
export async function getNewsList(limit: number): Promise<NewsListItem[]> {
try {
const rows = await cached<NewsListRow[]>(CACHE_KEY, TTL_MS, () =>
const rows = await cacheNews<NewsListRow[]>(CACHE_KEY, TTL_MS, () =>
db
.select({
slug: WebsiteArticles.slug,
@@ -60,7 +61,8 @@ export async function getNewsList(limit: number): Promise<NewsListItem[]> {
createdAt: a.createdAt ? new Date(a.createdAt) : null,
}))
.slice(0, limit);
} catch {
} catch (error) {
logger.error("Public news query failed", { module: "news", error });
return [];
}
}
+78
View File
@@ -0,0 +1,78 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
friends: [] as { id: number; username: string; online: string }[],
}));
vi.mock("@/lib/hotel-name", () => ({ resolveHotelName: async () => "Hotel" }));
vi.mock("./site-settings", () => ({
siteSettings: {
get: async (_key: string, fallback: string) => fallback,
getMany: async () => ({}),
},
}));
vi.mock("@/lib/db", async () => {
const schema = await import("@/db/schema");
return {
...schema,
db: {
select: (fields: Record<string, unknown>) => {
let rows: unknown[] = [];
const chain = {
from: (table: unknown) => {
if (table === schema.MessengerFriendships)
rows =
"value" in fields
? [{ value: state.friends.length }]
: state.friends;
return chain;
},
where: () => chain,
innerJoin: () => chain,
orderBy: () => chain,
limit: () => chain,
catch: () => Promise.resolve(rows),
// biome-ignore lint/suspicious/noThenProperty: Drizzle query builders are intentionally awaitable.
then: (resolve: (value: unknown[]) => unknown) =>
Promise.resolve(rows).then(resolve),
};
return chain;
},
},
};
});
import { loadUserDashboard } from "./user-dashboard";
describe("dashboard friendships", () => {
beforeEach(() => {
state.friends = [];
});
it("counts each friend once when both directional rows are returned", async () => {
const wait = { id: 2, username: "wait", online: "1" };
const other = { id: 3, username: "YouKnowMe", online: "1" };
state.friends = [wait, other, { ...wait }, { ...other }];
const data = await loadUserDashboard(1);
expect(data.friends.map((friend) => friend.id)).toEqual([2, 3]);
expect(data.friendCountRows).toEqual([{ value: 2 }]);
});
it("preserves single-direction and offline friendships", async () => {
state.friends = [
{ id: 2, username: "wait", online: "1" },
{ id: 3, username: "offline", online: "0" },
];
const data = await loadUserDashboard(1);
expect(data.friends).toEqual(state.friends);
expect(data.friendCountRows).toEqual([{ value: 2 }]);
});
it("excludes self friendships", async () => {
state.friends = [{ id: 1, username: "me", online: "1" }];
const data = await loadUserDashboard(1);
expect(data.friends).toEqual([]);
expect(data.friendCountRows).toEqual([{ value: 0 }]);
});
it("returns zero for an empty friends list", async () => {
const data = await loadUserDashboard(1);
expect(data.friends).toEqual([]);
expect(data.friendCountRows).toEqual([{ value: 0 }]);
});
});
+11 -13
View File
@@ -24,7 +24,6 @@ export async function loadUserDashboard(userId: number) {
recentRooms,
badges,
userSettingsRows,
friendCountRows,
unreadCountRows,
referralsRows,
friends,
@@ -82,16 +81,6 @@ export async function loadUserDashboard(userId: number) {
.where(eq(UsersSettings.userId, userId))
.limit(1)
.catch(() => []),
db
.select({ value: count() })
.from(MessengerFriendships)
.where(
or(
eq(MessengerFriendships.userOneId, userId),
eq(MessengerFriendships.userTwoId, userId),
),
)
.catch(() => [{ value: 0 }]),
db
.select({ value: count() })
.from(MessengerOffline)
@@ -145,6 +134,15 @@ export async function loadUserDashboard(userId: number) {
.catch(() => null),
]);
// Each friendship can have two directional rows; expose users, not edges.
const uniqueFriends = [
...new Map(
friends
.filter((friend) => friend.id !== userId)
.map((friend) => [friend.id, friend]),
).values(),
];
return {
userRows,
hotelName,
@@ -155,10 +153,10 @@ export async function loadUserDashboard(userId: number) {
recentRooms,
badges,
userSettingsRows,
friendCountRows,
friendCountRows: [{ value: uniqueFriends.length }],
unreadCountRows,
referralsRows,
friends,
friends: uniqueFriends,
currencyRows,
};
}
+4 -4
View File
@@ -81,10 +81,10 @@ describe("staff smoke contract", () => {
expect(readFileSync("src/lib/admin-nav.ts", "utf8")).not.toContain(
"/admin/menu",
);
expect(
readFileSync("src/components/admin/admin-sidebar-nav.tsx", "utf8"),
).toContain("applyAdminNavConfig");
expect(readFileSync("src/app/admin/layout.tsx", "utf8")).toContain(
expect(readFileSync("src/lib/admin-navigation.ts", "utf8")).toContain(
"applyAdminNavConfig",
);
expect(readFileSync("src/lib/admin/navigation.ts", "utf8")).toContain(
"ADMIN_NAV_CONFIG_KEY",
);
});