feat: add housekeeping domain registry

This commit is contained in:
Simo committed 2026-08-25 19:39:08 +02:00
1 parent 7edca410fa
commit 2d5f03048a
14 files changed
+694

No files matched your search

@@ -0,0 +1,22 @@
import { PERMS } from "@/lib/permission-slugs";
import {
anyCapability,
type HousekeepingDomainManifest,
} from "../../foundation/contracts";
export const contentManifest = {
id: "content",
labelKey: "pages.housekeeping.domains.content.title",
descriptionKey: "pages.housekeeping.domains.content.description",
iconId: "file-text",
previewHref: "/admin-next/content",
capability: anyCapability(
PERMS.NEWS_VIEW,
PERMS.PAGES_VIEW,
PERMS.BANNERS_VIEW,
PERMS.EVENTS_VIEW,
PERMS.POLLS_VIEW,
PERMS.PREFIXES_VIEW,
),
routes: [],
} satisfies HousekeepingDomainManifest;
@@ -0,0 +1,15 @@
import { PERMS } from "@/lib/permission-slugs";
import {
anyCapability,
type HousekeepingDomainManifest,
} from "../../foundation/contracts";
export const economyManifest = {
id: "economy",
labelKey: "pages.housekeeping.domains.economy.title",
descriptionKey: "pages.housekeeping.domains.economy.description",
iconId: "gem",
previewHref: "/admin-next/economy",
capability: anyCapability(PERMS.CATALOG_VIEW, PERMS.SHOP_VIEW),
routes: [],
} satisfies HousekeepingDomainManifest;
@@ -0,0 +1,19 @@
import { PERMS } from "@/lib/permission-slugs";
import {
anyCapability,
type HousekeepingDomainManifest,
} from "../../foundation/contracts";
export const hotelManifest = {
id: "hotel",
labelKey: "pages.housekeeping.domains.hotel.title",
descriptionKey: "pages.housekeeping.domains.hotel.description",
iconId: "hotel",
previewHref: "/admin-next/hotel",
capability: anyCapability(
PERMS.ROOMS_VIEW,
PERMS.RADIO_VIEW,
PERMS.ASSETS_IMPORT,
),
routes: [],
} satisfies HousekeepingDomainManifest;
@@ -0,0 +1,15 @@
import { PERMS } from "@/lib/permission-slugs";
import {
anyCapability,
type HousekeepingDomainManifest,
} from "../../foundation/contracts";
export const operationsManifest = {
id: "operations",
labelKey: "pages.housekeeping.domains.operations.title",
descriptionKey: "pages.housekeeping.domains.operations.description",
iconId: "inbox",
previewHref: "/admin-next/operations",
capability: anyCapability(PERMS.ADMIN_DASHBOARD),
routes: [],
} satisfies HousekeepingDomainManifest;
@@ -0,0 +1,26 @@
import { PERMS } from "@/lib/permission-slugs";
import {
anyCapability,
type HousekeepingDomainManifest,
} from "../../foundation/contracts";
export const peopleManifest = {
id: "people",
labelKey: "pages.housekeeping.domains.people.title",
descriptionKey: "pages.housekeeping.domains.people.description",
iconId: "users",
previewHref: "/admin-next/people",
capability: anyCapability(
PERMS.USERS_VIEW,
PERMS.MODERATION_VIEW,
PERMS.TICKETS_VIEW,
PERMS.BANS_VIEW,
PERMS.MOD_DASHBOARD,
PERMS.MOD_CFH_VIEW,
PERMS.MOD_TEAM_VIEW,
PERMS.MOD_TICKETS_VIEW,
PERMS.MOD_USERS_VIEW,
PERMS.MOD_BANS_VIEW,
),
routes: [],
} satisfies HousekeepingDomainManifest;
@@ -0,0 +1,23 @@
import { PERMS } from "@/lib/permission-slugs";
import {
anyCapability,
type HousekeepingDomainManifest,
} from "../../foundation/contracts";
export const systemManifest = {
id: "system",
labelKey: "pages.housekeeping.domains.system.title",
descriptionKey: "pages.housekeeping.domains.system.description",
iconId: "settings",
previewHref: "/admin-next/system",
capability: anyCapability(
PERMS.SETTINGS_VIEW,
PERMS.LOGS_VIEW,
PERMS.ANALYTICS_VIEW,
PERMS.DEVOPS_VIEW,
PERMS.NOTIFICATIONS_VIEW,
PERMS.PERMISSIONS_MANAGE,
PERMS.RCON_EXECUTE,
),
routes: [],
} satisfies HousekeepingDomainManifest;
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import en from "@/messages/en.json";
import itMessages from "@/messages/it.json";
import { HOUSEKEEPING_MANIFESTS } from "../manifests";
const requiredKeys = [
"pages.housekeeping.preview.badge",
"pages.housekeeping.preview.commandDisabled",
"pages.housekeeping.preview.backToSite",
"pages.housekeeping.navigation.skipToContent",
"pages.housekeeping.navigation.primary",
"pages.housekeeping.navigation.contextual",
"pages.housekeeping.states.loading.title",
"pages.housekeeping.states.loading.description",
"pages.housekeeping.states.empty.title",
"pages.housekeeping.states.empty.description",
"pages.housekeeping.states.partial.label",
"pages.housekeeping.states.partial.title",
"pages.housekeeping.states.partial.description",
"pages.housekeeping.states.error.title",
"pages.housekeeping.states.error.description",
];
function resolveMessage(messages: unknown, key: string): unknown {
return key.split(".").reduce<unknown>((value, segment) => {
if (value === null || typeof value !== "object") return undefined;
return (value as Record<string, unknown>)[segment];
}, messages);
}
describe("housekeeping localization contract", () => {
it.each([
["English", en],
["Italian", itMessages],
])("provides the planned housekeeping subtree in %s", (_locale, messages) => {
const housekeeping = resolveMessage(messages, "pages.housekeeping");
expect(housekeeping).toEqual(expect.any(Object));
expect(Object.keys(housekeeping as object)).toEqual([
"preview",
"navigation",
"domains",
"states",
]);
for (const key of [
...HOUSEKEEPING_MANIFESTS.flatMap((manifest) => [
manifest.labelKey,
manifest.descriptionKey,
]),
...requiredKeys,
]) {
expect(resolveMessage(messages, key), key).toEqual(expect.any(String));
}
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,39 @@
import type { HousekeepingDomainId } from "../migration/types";
import { satisfiesCapability } from "./capability-context";
import type {
HousekeepingCapabilityContext,
HousekeepingDomainManifest,
} from "./contracts";
import type { HousekeepingRegistry } from "./registry";
export interface HousekeepingNavigationDomain {
id: HousekeepingDomainId;
href: string;
iconId: HousekeepingDomainManifest["iconId"];
label: string;
description: string;
items: readonly { id: string; href: string; label: string }[];
}
export function buildHousekeepingNavigation(
registry: HousekeepingRegistry,
context: HousekeepingCapabilityContext,
translate: (key: string) => string,
): readonly HousekeepingNavigationDomain[] {
return registry.domains
.filter((domain) => satisfiesCapability(context, domain.capability))
.map((domain) => ({
id: domain.id,
href: domain.previewHref,
iconId: domain.iconId,
label: translate(domain.labelKey),
description: translate(domain.descriptionKey),
items: domain.routes
.filter((route) => satisfiesCapability(context, route.capability))
.map((route) => ({
id: route.id,
href: route.href,
label: translate(route.labelKey),
})),
}));
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,75 @@
import { PERMS } from "@/lib/permission-slugs";
import { HOUSEKEEPING_DOMAIN_IDS } from "../migration/types";
import type {
CapabilityRequirement,
HousekeepingDomainManifest,
} from "./contracts";
const approvedDomainIds = new Set<string>(HOUSEKEEPING_DOMAIN_IDS);
const knownCapabilitySlugs = new Set<string>(Object.values(PERMS));
export interface HousekeepingRegistry {
domains: readonly HousekeepingDomainManifest[];
}
export function createHousekeepingRegistry(
manifests: readonly HousekeepingDomainManifest[],
): HousekeepingRegistry {
const domainIds = new Set<string>();
const routeIds = new Set<string>();
const routeHrefs = new Set<string>();
for (const manifest of manifests) {
if (!approvedDomainIds.has(manifest.id)) {
throw new Error(`unknown domain id: ${manifest.id}`);
}
if (domainIds.has(manifest.id)) {
throw new Error(`duplicate domain id: ${manifest.id}`);
}
domainIds.add(manifest.id);
if (manifest.previewHref !== `/admin-next/${manifest.id}`) {
throw new Error(`invalid preview href: ${manifest.previewHref}`);
}
validateNonEmpty(manifest.labelKey, "label key");
validateNonEmpty(manifest.descriptionKey, "description key");
validateCapability(manifest.capability);
for (const route of manifest.routes) {
validateNonEmpty(route.id, "route id");
if (routeIds.has(route.id)) {
throw new Error(`duplicate route id: ${route.id}`);
}
routeIds.add(route.id);
validateNonEmpty(route.href, "route href");
if (routeHrefs.has(route.href)) {
throw new Error(`duplicate route href: ${route.href}`);
}
routeHrefs.add(route.href);
validateNonEmpty(route.labelKey, "route label key");
validateCapability(route.capability);
}
}
return { domains: Object.freeze([...manifests]) };
}
function validateNonEmpty(value: string, name: string): void {
if (!value.trim()) {
throw new Error(`empty ${name}`);
}
}
function validateCapability(requirement: CapabilityRequirement): void {
if (requirement.slugs.length === 0) {
throw new Error("empty capability requirement");
}
for (const slug of requirement.slugs) {
if (!knownCapabilitySlugs.has(slug)) {
throw new Error(`unknown capability slug: ${slug}`);
}
}
}
+16
View File
@@ -0,0 +1,16 @@
import { contentManifest } from "./domains/content/manifest";
import { economyManifest } from "./domains/economy/manifest";
import { hotelManifest } from "./domains/hotel/manifest";
import { operationsManifest } from "./domains/operations/manifest";
import { peopleManifest } from "./domains/people/manifest";
import { systemManifest } from "./domains/system/manifest";
import type { HousekeepingDomainManifest } from "./foundation/contracts";
export const HOUSEKEEPING_MANIFESTS = [
operationsManifest,
peopleManifest,
contentManifest,
economyManifest,
hotelManifest,
systemManifest,
] as const satisfies readonly HousekeepingDomainManifest[];
+57
View File
@@ -3255,6 +3255,63 @@
"failed": "Failed"
}
}
},
"housekeeping": {
"preview": {
"badge": "Foundation preview",
"commandDisabled": "Search and commands are enabled in a later subproject.",
"backToSite": "Back to site"
},
"navigation": {
"skipToContent": "Skip to content",
"primary": "Housekeeping domains",
"contextual": "Domain navigation"
},
"domains": {
"operations": {
"title": "Operations",
"description": "Operational overview and daily queues"
},
"people": {
"title": "People",
"description": "Users, moderation, and support"
},
"content": {
"title": "Content",
"description": "Editorial content and community engagement"
},
"economy": {
"title": "Economy",
"description": "Catalog and shop operations"
},
"hotel": {
"title": "Hotel",
"description": "Rooms, radio, and asset tools"
},
"system": {
"title": "System",
"description": "Configuration, observability, and access"
}
},
"states": {
"loading": {
"title": "Loading housekeeping",
"description": "Preparing the available housekeeping tools."
},
"empty": {
"title": "Nothing available",
"description": "No housekeeping content is available for this domain."
},
"partial": {
"label": "Partial data",
"title": "Some information is unavailable",
"description": "Review the available information and try again later."
},
"error": {
"title": "Unable to load housekeeping",
"description": "Try again later or contact an administrator."
}
}
}
}
}
+57
View File
@@ -3260,6 +3260,63 @@
"failed": "Failed"
}
}
},
"housekeeping": {
"preview": {
"badge": "Anteprima della base",
"commandDisabled": "Ricerca e comandi saranno abilitati in un sottoprogetto successivo.",
"backToSite": "Torna al sito"
},
"navigation": {
"skipToContent": "Vai al contenuto",
"primary": "Domini housekeeping",
"contextual": "Navigazione del dominio"
},
"domains": {
"operations": {
"title": "Operazioni",
"description": "Panoramica operativa e code giornaliere"
},
"people": {
"title": "Persone",
"description": "Utenti, moderazione e supporto"
},
"content": {
"title": "Contenuti",
"description": "Contenuti editoriali e coinvolgimento della community"
},
"economy": {
"title": "Economia",
"description": "Operazioni di catalogo e shop"
},
"hotel": {
"title": "Hotel",
"description": "Stanze, radio e strumenti per gli asset"
},
"system": {
"title": "Sistema",
"description": "Configurazione, osservabilità e accesso"
}
},
"states": {
"loading": {
"title": "Caricamento housekeeping",
"description": "Preparazione degli strumenti housekeeping disponibili."
},
"empty": {
"title": "Nessun contenuto disponibile",
"description": "Non ci sono contenuti housekeeping disponibili per questo dominio."
},
"partial": {
"label": "Dati parziali",
"title": "Alcune informazioni non sono disponibili",
"description": "Controlla le informazioni disponibili e riprova più tardi."
},
"error": {
"title": "Impossibile caricare housekeeping",
"description": "Riprova più tardi o contatta un amministratore."
}
}
}
}
}