Merge branch 'main' of https://gitlab.epicnabbo.nl/remco/epicnext-cms
This commit is contained in:
commit
fea8023ac6
87 files changed
+3760
-1105
No files matched your search
+13
-1
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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([]),
|
||||
);
|
||||
@@ -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"),
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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 }]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user