Merge pull request 'feat: add housekeeping inventory foundation' (#51) from codex/housekeeping-foundation into main
CI / check (push) Successful in 28s
CI / release (push) Skipped
CI / deploy (push) Successful in 42s

Reviewed-on: #51
This commit was merged in pull request #51.
This commit is contained in:
Simo committed 2026-08-26 19:50:40 +02:00
commit d5eadeb3a7
71 files changed
+10209 -3

No files matched your search

+2
View File
@@ -17,6 +17,8 @@ NODE_ENV=production
PORT=3002
NEXT_TELEMETRY_DISABLED=1
UV_THREADPOOL_SIZE=16
# Non-production preview only; production always returns 404.
HOUSEKEEPING_NEXT_PREVIEW_ENABLED=false
# --- HOTEL & URLS ---
HOTEL_NAME=EPIC WEB CONTROL
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -21,7 +21,9 @@
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx scripts/apply-migrations.ts",
"db:migrate:status": "tsx scripts/apply-migrations.ts --status",
"db:studio": "drizzle-kit studio"
"db:studio": "drizzle-kit studio",
"hk:matrix:check": "tsx scripts/verify-housekeeping-matrix.ts",
"test:housekeeping": "vitest run --coverage.enabled=false src/features/housekeeping src/lib/admin-theme-source-audit.test.ts src/lib/admin/authorization-contract.test.ts"
},
"lint-staged": {
"*.{js,ts,jsx,tsx,json}": "biome check --write --no-errors-on-unmatched"
+18
View File
@@ -0,0 +1,18 @@
import { discoverLegacyPages } from "../src/features/housekeeping/migration/discover-legacy-pages";
import { HOUSEKEEPING_MIGRATION_MATRIX } from "../src/features/housekeeping/migration/matrix";
import { validateMigrationEntries } from "../src/features/housekeeping/migration/validate-matrix";
const discovered = discoverLegacyPages();
const issues = validateMigrationEntries(
discovered,
HOUSEKEEPING_MIGRATION_MATRIX,
);
if (issues.length > 0) {
for (const issue of issues) console.error(issue);
process.exitCode = 1;
} else {
console.log(
`Housekeeping migration matrix: ${HOUSEKEEPING_MIGRATION_MATRIX.length}/${discovered.length} valid`,
);
}
+59
View File
@@ -0,0 +1,59 @@
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import type { ReactNode } from "react";
import { satisfiesCapability } from "@/features/housekeeping/foundation/capability-context";
import { buildHousekeepingNavigation } from "@/features/housekeeping/foundation/navigation";
import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
import { HousekeepingShell } from "@/features/housekeeping/foundation/shell/housekeeping-shell";
import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests";
const MESSAGE_PREFIX = "pages.housekeeping.";
function namespaceKey(key: string): string {
if (!key.startsWith(MESSAGE_PREFIX)) {
throw new Error(`invalid housekeeping message key: ${key}`);
}
return key.slice(MESSAGE_PREFIX.length);
}
export default async function AdminNextDomainLayout({
children,
params,
}: {
children: ReactNode;
params: Promise<{ domain: string }>;
}) {
const { domain } = await params;
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
const activeDomain = registry.domains.find((entry) => entry.id === domain);
if (!activeDomain) notFound();
const context = await getHousekeepingCapabilityContext();
if (!satisfiesCapability(context, activeDomain.capability)) notFound();
const translate = await getTranslations("pages.housekeeping");
const navigation = buildHousekeepingNavigation(registry, context, (key) =>
translate(namespaceKey(key) as never),
);
return (
<HousekeepingShell
actor={context.actor}
activeDomainId={activeDomain.id}
domains={navigation}
labels={{
skipToContent: translate("navigation.skipToContent"),
primaryNavigation: translate("navigation.primary"),
contextualNavigation: translate("navigation.contextual"),
command: translate("preview.commandDisabled"),
preview: translate("preview.badge"),
backToSite: translate("preview.backToSite"),
}}
>
{children}
</HousekeepingShell>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { HousekeepingPageShell } from "@/features/housekeeping/foundation/page/housekeeping-page-shell";
import { HousekeepingPageState } from "@/features/housekeeping/foundation/page/housekeeping-page-state";
import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests";
const MESSAGE_PREFIX = "pages.housekeeping.";
function namespaceKey(key: string): string {
if (!key.startsWith(MESSAGE_PREFIX)) {
throw new Error(`invalid housekeeping message key: ${key}`);
}
return key.slice(MESSAGE_PREFIX.length);
}
export default async function AdminNextDomainPage({
params,
}: {
params: Promise<{ domain: string }>;
}) {
const { domain } = await params;
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
const activeDomain = registry.domains.find((entry) => entry.id === domain);
if (!activeDomain) notFound();
const translate = await getTranslations("pages.housekeeping");
return (
<HousekeepingPageShell
title={translate(namespaceKey(activeDomain.labelKey) as never)}
description={translate(
namespaceKey(activeDomain.descriptionKey) as never,
)}
>
<HousekeepingPageState
state="empty"
title={translate("states.empty.title")}
description={translate("states.empty.description")}
/>
</HousekeepingPageShell>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { notFound } from "next/navigation";
import type { ReactNode } from "react";
import { env } from "@/env";
import { isHousekeepingPreviewEnabled } from "@/features/housekeeping/foundation/preview-gate";
export default function AdminNextLayout({ children }: { children: ReactNode }) {
if (
!isHousekeepingPreviewEnabled({
nodeEnv: env.NODE_ENV,
flag: env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED,
})
) {
notFound();
}
return children;
}
+17
View File
@@ -0,0 +1,17 @@
import { notFound, redirect } from "next/navigation";
import { satisfiesCapability } from "@/features/housekeeping/foundation/capability-context";
import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests";
export default async function AdminNextPage() {
const context = await getHousekeepingCapabilityContext();
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
const firstVisibleDomain = registry.domains.find((domain) =>
satisfiesCapability(context, domain.capability),
);
if (!firstVisibleDomain) notFound();
redirect(firstVisibleDomain.previewHref);
}
+4
View File
@@ -9,6 +9,10 @@ const schema = z
NODE_ENV: z
.enum(["development", "test", "production"])
.default("development"),
HOUSEKEEPING_NEXT_PREVIEW_ENABLED: z
.string()
.optional()
.transform((value) => value === "true" || value === "1"),
DATABASE_URL: z.string().url(),
DATABASE_POOL_SIZE: z.coerce.number().int().positive().default(10),
DATABASE_IDLE_TIMEOUT_MS: z.coerce
@@ -0,0 +1,33 @@
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,
PERMS.NEWS_EDIT,
PERMS.PAGES_EDIT,
PERMS.BANNERS_EDIT,
PERMS.EVENTS_EDIT,
PERMS.POLLS_EDIT,
PERMS.PREFIXES_EDIT,
PERMS.SETTINGS_VIEW,
PERMS.SETTINGS_EDIT,
),
routes: [],
searchProviders: [],
inboxSources: [],
widgets: [],
} satisfies HousekeepingDomainManifest;
@@ -0,0 +1,23 @@
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,
PERMS.CATALOG_EDIT,
PERMS.SHOP_EDIT,
),
routes: [],
searchProviders: [],
inboxSources: [],
widgets: [],
} satisfies HousekeepingDomainManifest;
@@ -0,0 +1,27 @@
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,
PERMS.ROOMS_EDIT,
PERMS.ROOMS_DELETE,
PERMS.RADIO_EDIT,
PERMS.PAGES_VIEW,
PERMS.CATALOG_EDIT,
),
routes: [],
searchProviders: [],
inboxSources: [],
widgets: [],
} satisfies HousekeepingDomainManifest;
@@ -0,0 +1,18 @@
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: [],
searchProviders: [],
inboxSources: [],
widgets: [],
} satisfies HousekeepingDomainManifest;
@@ -0,0 +1,41 @@
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,
PERMS.USERS_EDIT,
PERMS.USERS_BAN,
PERMS.USERS_RESET_PASSWORD,
PERMS.MODERATION_EDIT,
PERMS.TICKETS_EDIT,
PERMS.SETTINGS_VIEW,
PERMS.SETTINGS_EDIT,
PERMS.WORDFILTER_VIEW,
PERMS.WORDFILTER_EDIT,
PERMS.MOD_ACTIONS,
PERMS.MOD_CFH_EDIT,
PERMS.MOD_TICKETS_EDIT,
),
routes: [],
searchProviders: [],
inboxSources: [],
widgets: [],
} satisfies HousekeepingDomainManifest;
@@ -0,0 +1,28 @@
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,
PERMS.SETTINGS_EDIT,
PERMS.NOTIFICATIONS_EDIT,
),
routes: [],
searchProviders: [],
inboxSources: [],
widgets: [],
} satisfies HousekeepingDomainManifest;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,30 @@
import type { PermissionSet } from "@/lib/permissions";
import type {
CapabilityRequirement,
HousekeepingActor,
HousekeepingCapabilityContext,
} from "./contracts";
export function createHousekeepingCapabilityContext(
actor: HousekeepingActor,
permissions: PermissionSet,
): HousekeepingCapabilityContext {
return {
actor,
isSuperAdmin: permissions.isSuperAdmin,
has: (slug) => permissions.has(slug),
hasAny: (...slugs) => permissions.hasAny(...slugs),
hasAll: (...slugs) => permissions.hasAll(...slugs),
};
}
export function satisfiesCapability(
context: HousekeepingCapabilityContext,
requirement: CapabilityRequirement,
): boolean {
if (context.isSuperAdmin) return true;
return requirement.mode === "any"
? context.hasAny(...requirement.slugs)
: context.hasAll(...requirement.slugs);
}
@@ -0,0 +1,36 @@
export type CapabilityRequirement =
| { mode: "all"; slugs: readonly string[] }
| { mode: "any"; slugs: readonly string[] };
export interface HousekeepingActor {
id: number;
username: string;
rank: number;
}
export interface HousekeepingCapabilityContext {
actor: HousekeepingActor;
isSuperAdmin: boolean;
has(slug: string): boolean;
hasAny(...slugs: string[]): boolean;
hasAll(...slugs: string[]): boolean;
}
function createCapabilityRequirement(
mode: CapabilityRequirement["mode"],
slugs: readonly string[],
): CapabilityRequirement {
if (slugs.length === 0) {
throw new Error("capability requirement is empty");
}
return { mode, slugs };
}
export function anyCapability(...slugs: string[]): CapabilityRequirement {
return createCapabilityRequirement("any", slugs);
}
export function allCapabilities(...slugs: string[]): CapabilityRequirement {
return createCapabilityRequirement("all", slugs);
}
@@ -0,0 +1,18 @@
import type { HousekeepingDomainId } from "../../migration/types";
import type {
CapabilityRequirement,
HousekeepingCapabilityContext,
} from "./capability";
import type { HousekeepingResult } from "./result";
export interface HousekeepingCommand<I, O> {
id: string;
owner: HousekeepingDomainId;
risk: "safe" | "sensitive";
capability: CapabilityRequirement;
requiresReason: boolean;
execute(
context: HousekeepingCapabilityContext,
input: I,
): Promise<HousekeepingResult<O>>;
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,26 @@
import type { HousekeepingDomainId } from "../../migration/types";
import type { CapabilityRequirement } from "./capability";
import type { HousekeepingInboxSource } from "./inbox";
import type { HousekeepingSearchProvider } from "./search";
import type { HousekeepingWidgetDefinition } from "./widget";
export interface HousekeepingRouteDefinition {
id: string;
labelKey: string;
href: string;
capability: CapabilityRequirement;
matchPrefixes?: readonly string[];
}
export interface HousekeepingDomainManifest {
id: HousekeepingDomainId;
labelKey: string;
descriptionKey: string;
iconId: "inbox" | "users" | "file-text" | "gem" | "hotel" | "settings";
previewHref: `/admin-next/${HousekeepingDomainId}`;
capability: CapabilityRequirement;
routes: readonly HousekeepingRouteDefinition[];
searchProviders: readonly HousekeepingSearchProvider[];
inboxSources: readonly HousekeepingInboxSource[];
widgets: readonly HousekeepingWidgetDefinition[];
}
@@ -0,0 +1,39 @@
import type { HousekeepingDomainId } from "../../migration/types";
import type {
CapabilityRequirement,
HousekeepingCapabilityContext,
} from "./capability";
import type { HousekeepingResult } from "./result";
export type HousekeepingWorkItemSeverity = "info" | "warning" | "critical";
export type HousekeepingWorkItemFreshness = "fresh" | "stale";
export type HousekeepingInboxSourceAvailability = "available" | "unavailable";
export interface HousekeepingWorkItem {
sourceId: string;
itemId: string;
deduplicationKey: string;
domain: HousekeepingDomainId;
capability: CapabilityRequirement;
severity: HousekeepingWorkItemSeverity;
occurredAt: string;
titleKey: string;
context?: Readonly<Record<string, unknown>>;
href: string;
freshness: HousekeepingWorkItemFreshness;
}
export interface HousekeepingInboxSourceResult {
items: readonly HousekeepingWorkItem[];
availability: HousekeepingInboxSourceAvailability;
}
export interface HousekeepingInboxSource {
id: string;
owner: HousekeepingDomainId;
capability: CapabilityRequirement;
getItems(
context: HousekeepingCapabilityContext,
signal: AbortSignal,
): Promise<HousekeepingResult<HousekeepingInboxSourceResult>>;
}
@@ -0,0 +1,36 @@
export {
allCapabilities,
anyCapability,
type CapabilityRequirement,
type HousekeepingActor,
type HousekeepingCapabilityContext,
} from "./capability";
export type { HousekeepingCommand } from "./command";
export type {
HousekeepingDomainManifest,
HousekeepingRouteDefinition,
} from "./domain";
export type {
HousekeepingInboxSource,
HousekeepingInboxSourceAvailability,
HousekeepingInboxSourceResult,
HousekeepingWorkItem,
HousekeepingWorkItemFreshness,
HousekeepingWorkItemSeverity,
} from "./inbox";
export type { HousekeepingQuery } from "./query";
export {
fail,
type HousekeepingErrorCode,
type HousekeepingResult,
ok,
} from "./result";
export type {
HousekeepingSearchInput,
HousekeepingSearchProvider,
HousekeepingSearchResult,
} from "./search";
export type {
HousekeepingWidgetDefinition,
HousekeepingWidgetKind,
} from "./widget";
@@ -0,0 +1,16 @@
import type { HousekeepingDomainId } from "../../migration/types";
import type {
CapabilityRequirement,
HousekeepingCapabilityContext,
} from "./capability";
import type { HousekeepingResult } from "./result";
export interface HousekeepingQuery<I, O> {
id: string;
owner: HousekeepingDomainId;
capability: CapabilityRequirement;
run(
context: HousekeepingCapabilityContext,
input: I,
): Promise<HousekeepingResult<O>>;
}
@@ -0,0 +1,28 @@
export type HousekeepingErrorCode =
| "VALIDATION_FAILED"
| "AUTHENTICATION_REQUIRED"
| "CAPABILITY_DENIED"
| "NOT_FOUND"
| "CONFLICT"
| "DEPENDENCY_UNAVAILABLE"
| "PARTIAL_RESULT"
| "INTERNAL_ERROR";
export type HousekeepingResult<T> =
| { ok: true; data: T; correlationId: string }
| {
ok: false;
error: { code: HousekeepingErrorCode };
correlationId: string;
};
export function ok<T>(data: T, correlationId: string): HousekeepingResult<T> {
return { ok: true, data, correlationId };
}
export function fail(
code: HousekeepingErrorCode,
correlationId: string,
): HousekeepingResult<never> {
return { ok: false, error: { code }, correlationId };
}
@@ -0,0 +1,28 @@
import type { HousekeepingDomainId } from "../../migration/types";
import type {
CapabilityRequirement,
HousekeepingCapabilityContext,
} from "./capability";
import type { HousekeepingResult } from "./result";
export interface HousekeepingSearchInput {
term: string;
limit: number;
}
export interface HousekeepingSearchResult {
id: string;
domain: HousekeepingDomainId;
title: string;
href: string;
}
export interface HousekeepingSearchProvider {
id: string;
owner: HousekeepingDomainId;
capability: CapabilityRequirement;
search(
context: HousekeepingCapabilityContext,
input: HousekeepingSearchInput,
): Promise<HousekeepingResult<readonly HousekeepingSearchResult[]>>;
}
@@ -0,0 +1,18 @@
import type { HousekeepingDomainId } from "../../migration/types";
import type {
CapabilityRequirement,
HousekeepingCapabilityContext,
} from "./capability";
import type { HousekeepingResult } from "./result";
export type HousekeepingWidgetKind = "mandatory" | "optional";
export interface HousekeepingWidgetDefinition {
id: string;
owner: HousekeepingDomainId;
capability: CapabilityRequirement;
kind: HousekeepingWidgetKind;
load(
context: HousekeepingCapabilityContext,
): Promise<HousekeepingResult<unknown>>;
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
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),
})),
}));
}
@@ -0,0 +1,35 @@
import type { ReactNode } from "react";
interface HousekeepingPageShellProps {
title: string;
description: string;
primaryAction?: ReactNode;
context?: ReactNode;
children: ReactNode;
}
export function HousekeepingPageShell({
title,
description,
primaryAction,
context,
children,
}: HousekeepingPageShellProps) {
return (
<section className="space-y-6">
<header className="flex flex-wrap items-start justify-between gap-4 border-b border-[var(--admin-border)] pb-4">
<div className="min-w-0 space-y-1">
<h1 className="text-xl font-semibold text-[var(--admin-text)]">
{title}
</h1>
<p className="text-sm text-[var(--admin-text-muted)]">
{description}
</p>
{context ? <div>{context}</div> : null}
</div>
{primaryAction ? <div>{primaryAction}</div> : null}
</header>
<div>{children}</div>
</section>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,52 @@
import type { ReactNode } from "react";
interface HousekeepingPageStateBaseProps {
title: string;
description: string;
retryAction?: ReactNode;
}
type HousekeepingPageStateProps = HousekeepingPageStateBaseProps &
(
| { state: "partial"; partialLabel: string }
| { state: "loading" | "empty" | "error"; partialLabel?: never }
);
export function HousekeepingPageState({
state,
partialLabel,
title,
description,
retryAction,
}: HousekeepingPageStateProps) {
const isError = state === "error";
const isPartial = state === "partial";
return (
<section
role={isError ? "alert" : "status"}
aria-live={state === "loading" ? "polite" : undefined}
className={`rounded-lg border p-4 ${
isError
? "border-[var(--admin-error)] bg-[var(--admin-surface)]"
: isPartial
? "border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]"
: "border-[var(--admin-border)] bg-[var(--admin-surface)]"
}`}
>
{isPartial ? (
<p
data-state-tone="warning"
className="mb-2 inline-flex rounded-full border border-[var(--admin-warning-border)] bg-[var(--admin-surface)] px-2 py-0.5 text-xs font-medium text-[var(--admin-warning)]"
>
{partialLabel}
</p>
) : null}
<h2 className="font-medium text-[var(--admin-text)]">{title}</h2>
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
{description}
</p>
{retryAction ? <div className="mt-3">{retryAction}</div> : null}
</section>
);
}
@@ -0,0 +1,58 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { isHousekeepingPreviewEnabled } from "./preview-gate";
describe("isHousekeepingPreviewEnabled", () => {
it.each([
["development", true, true],
["test", true, true],
["development", false, false],
["production", true, false],
["production", false, false],
] as const)("NODE_ENV=%s flag=%s => %s", (nodeEnv, flag, expected) => {
expect(isHousekeepingPreviewEnabled({ nodeEnv, flag })).toBe(expected);
});
});
describe("HOUSEKEEPING_NEXT_PREVIEW_ENABLED", () => {
const originalSkipValidation = process.env.SKIP_ENV_VALIDATION;
const originalPreviewFlag = process.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED;
beforeEach(() => {
vi.resetModules();
delete process.env.SKIP_ENV_VALIDATION;
});
afterEach(() => {
if (originalSkipValidation === undefined) {
delete process.env.SKIP_ENV_VALIDATION;
} else {
process.env.SKIP_ENV_VALIDATION = originalSkipValidation;
}
if (originalPreviewFlag === undefined) {
delete process.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED;
} else {
process.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED = originalPreviewFlag;
}
vi.resetModules();
});
it.each([
["true", true],
["1", true],
["false", false],
["yes", false],
[undefined, false],
] as const)("normalizes %s to %s", async (value, expected) => {
if (value === undefined) {
delete process.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED;
} else {
process.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED = value;
}
const { env } = await import("@/env");
expect(env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED).toBe(expected);
});
});
@@ -0,0 +1,6 @@
export function isHousekeepingPreviewEnabled(input: {
nodeEnv: "development" | "test" | "production";
flag: boolean;
}): boolean {
return input.nodeEnv !== "production" && input.flag;
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { auth, getAdminContext, staleActor } = vi.hoisted(() => ({
auth: vi.fn(),
getAdminContext: vi.fn(),
staleActor: { id: 9, username: "stale-session", rank: 1 },
}));
vi.mock("react", () => ({
cache: <T extends (...args: never[]) => unknown>(callback: T) => callback,
}));
vi.mock("@/lib/auth", () => ({ auth }));
vi.mock("@/lib/db", () => {
throw new Error("server capability context must not access the database");
});
vi.mock("@/lib/permissions", () => ({ getAdminContext }));
import { auth as mockedAuth } from "@/lib/auth";
import { getHousekeepingCapabilityContext } from "./server-capability-context";
describe("getHousekeepingCapabilityContext", () => {
beforeEach(() => {
vi.clearAllMocks();
auth.mockResolvedValue({ user: staleActor });
getAdminContext.mockResolvedValue({
session: {
user: {
id: 42,
username: "operator",
rank: 7,
},
},
permissions: {
isSuperAdmin: false,
has: (slug: string) => slug === "admin.users.view",
hasAny: (...slugs: string[]) => slugs.includes("admin.users.view"),
hasAll: (...slugs: string[]) =>
slugs.every((slug) => slug === "admin.users.view"),
},
});
});
it("uses the refreshed administrator actor instead of the stale auth actor", async () => {
const context = await getHousekeepingCapabilityContext();
expect(context).toMatchObject({
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
});
expect(context.has("admin.users.view")).toBe(true);
expect(context.has("admin.logs.view")).toBe(false);
expect(getAdminContext).toHaveBeenCalledTimes(1);
expect(mockedAuth).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,16 @@
import { cache } from "react";
import { getAdminContext } from "@/lib/permissions";
import { createHousekeepingCapabilityContext } from "./capability-context";
export const getHousekeepingCapabilityContext = cache(async () => {
const { session, permissions } = await getAdminContext();
return createHousekeepingCapabilityContext(
{
id: session.user.id,
username: session.user.username,
rank: session.user.rank,
},
permissions,
);
});
@@ -0,0 +1,15 @@
interface CommandTriggerProps {
label: string;
}
export function CommandTrigger({ label }: CommandTriggerProps) {
return (
<button
type="button"
disabled
className="rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2 text-sm text-[var(--admin-text-muted)]"
>
{label}
</button>
);
}
@@ -0,0 +1,25 @@
import Link from "next/link";
interface ContextNavProps {
items: readonly { id: string; href: string; label: string }[];
ariaLabel: string;
}
export function ContextNav({ items, ariaLabel }: ContextNavProps) {
return (
<nav aria-label={ariaLabel} className="min-w-0">
<ul className="flex gap-2 overflow-x-auto pb-1 lg:flex-col lg:overflow-visible">
{items.map((item) => (
<li key={item.id} className="min-w-max">
<Link
href={item.href}
className="block rounded-md px-3 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-canvas)] hover:text-[var(--admin-text)]"
>
{item.label}
</Link>
</li>
))}
</ul>
</nav>
);
}
@@ -0,0 +1,49 @@
import { FileText, Gem, Hotel, Inbox, Settings, Users } from "lucide-react";
import Link from "next/link";
import type { HousekeepingNavigationDomain } from "../navigation";
interface DomainRailProps {
domains: readonly HousekeepingNavigationDomain[];
activeDomainId: HousekeepingNavigationDomain["id"];
ariaLabel: string;
}
function DomainIcon({ iconId }: Pick<HousekeepingNavigationDomain, "iconId">) {
const Icon = {
inbox: Inbox,
users: Users,
"file-text": FileText,
gem: Gem,
hotel: Hotel,
settings: Settings,
}[iconId];
return <Icon aria-hidden="true" size={18} />;
}
export function DomainRail({
domains,
activeDomainId,
ariaLabel,
}: DomainRailProps) {
return (
<nav aria-label={ariaLabel} className="w-full lg:w-20">
<ul className="flex gap-1 overflow-x-auto lg:flex-col lg:overflow-visible">
{domains.map((domain) => (
<li key={domain.id} className="min-w-max lg:min-w-0">
<Link
href={domain.href}
aria-current={domain.id === activeDomainId ? "page" : undefined}
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-surface)] hover:text-[var(--admin-text)] lg:justify-center"
>
<span data-domain-icon={domain.iconId}>
<DomainIcon iconId={domain.iconId} />
</span>
<span className="lg:sr-only">{domain.label}</span>
</Link>
</li>
))}
</ul>
</nav>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,80 @@
import Link from "next/link";
import type { ReactNode } from "react";
import type { HousekeepingDomainId } from "../../migration/types";
import type { HousekeepingActor } from "../contracts";
import type { HousekeepingNavigationDomain } from "../navigation";
import { CommandTrigger } from "./command-trigger";
import { ContextNav } from "./context-nav";
import { DomainRail } from "./domain-rail";
import { OperatorSummary } from "./operator-summary";
interface HousekeepingShellProps {
actor: HousekeepingActor;
activeDomainId: HousekeepingDomainId;
domains: readonly HousekeepingNavigationDomain[];
labels: {
skipToContent: string;
primaryNavigation: string;
contextualNavigation: string;
command: string;
preview: string;
backToSite: string;
};
children: ReactNode;
}
export function HousekeepingShell({
actor,
activeDomainId,
domains,
labels,
children,
}: HousekeepingShellProps) {
const activeDomain = domains.find((domain) => domain.id === activeDomainId);
return (
<div className="min-h-screen bg-[var(--admin-canvas)] text-[var(--admin-text)]">
<a
href="#housekeeping-content"
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-10 rounded-md bg-[var(--admin-surface)] px-3 py-2 text-[var(--admin-text)]"
>
{labels.skipToContent}
</a>
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-3">
<div className="flex items-center gap-3">
<span className="rounded-full border border-[var(--admin-border)] px-2 py-1 text-xs text-[var(--admin-accent)]">
{labels.preview}
</span>
<OperatorSummary actor={actor} />
</div>
<div className="flex items-center gap-2">
<CommandTrigger label={labels.command} />
<Link
href="/"
className="rounded-md px-3 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-canvas)] hover:text-[var(--admin-text)]"
>
{labels.backToSite}
</Link>
</div>
</header>
<div className="flex min-h-[calc(100vh-4rem)] flex-col lg:flex-row">
<aside className="border-b border-[var(--admin-border)] bg-[var(--admin-surface)] p-3 lg:border-b-0 lg:border-r">
<DomainRail
domains={domains}
activeDomainId={activeDomainId}
ariaLabel={labels.primaryNavigation}
/>
</aside>
<div className="border-b border-[var(--admin-border)] bg-[var(--admin-surface)] p-3 lg:w-56 lg:border-b-0 lg:border-r">
<ContextNav
items={activeDomain?.items ?? []}
ariaLabel={labels.contextualNavigation}
/>
</div>
<main id="housekeeping-content" className="min-w-0 flex-1 p-4 lg:p-6">
{children}
</main>
</div>
</div>
);
}
@@ -0,0 +1,19 @@
import type { HousekeepingActor } from "../contracts";
interface OperatorSummaryProps {
actor: HousekeepingActor;
}
export function OperatorSummary({ actor }: OperatorSummaryProps) {
return (
<div className="min-w-0 text-sm text-[var(--admin-text)]">
<div className="truncate font-medium">{actor.username}</div>
<div
data-operator-rank={actor.rank}
className="text-[var(--admin-text-muted)]"
>
{actor.rank}
</div>
</div>
);
}
+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[];
@@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import { contentMigrationEntries } from "./content";
import { ownedLegacyPages } from "./discover-legacy-pages";
import { validateMigrationEntries } from "./validate-matrix";
const CONTENT_PREFIXES = [
"/admin/articles",
"/admin/photos",
"/admin/media",
"/admin/banners",
"/admin/ads",
"/admin/events",
"/admin/polls",
"/admin/help-questions",
"/admin/tags",
"/admin/prefixes",
"/admin/writeable-boxes",
"/admin/email-templates",
"/admin/theme",
"/admin/favicon",
"/admin/translations",
] as const;
describe("contentMigrationEntries", () => {
it("covers every content page without legacy-domain targets", () => {
const expected = ownedLegacyPages(CONTENT_PREFIXES);
expect(validateMigrationEntries(expected, contentMigrationEntries)).toEqual(
[],
);
expect(
contentMigrationEntries.every(
(row) =>
row.targetPath === null ||
row.targetPath.startsWith("/admin/content/"),
),
).toBe(true);
});
it("preserves dynamic ids in the consolidated workflow targets", () => {
expect(
contentMigrationEntries
.filter((row) => row.legacyPath.endsWith(":id"))
.map(({ legacyPath, targetPath }) => ({ legacyPath, targetPath })),
).toEqual([
{
legacyPath: "/admin/articles/:id",
targetPath: "/admin/content/editorial/articles/:id",
},
{
legacyPath: "/admin/ads/:id",
targetPath: "/admin/content/media/ads/:id",
},
{
legacyPath: "/admin/events/:id",
targetPath: "/admin/content/engagement/events/:id",
},
{
legacyPath: "/admin/polls/:id",
targetPath: "/admin/content/engagement/polls/:id",
},
{
legacyPath: "/admin/help-questions/:id",
targetPath: "/admin/content/help/questions/:id",
},
]);
});
it("keeps every audited workflow planned without parity claims", () => {
expect(
contentMigrationEntries.every(
(row) => row.status === "PLANNED" && row.parityEvidence.length === 0,
),
).toBe(true);
});
it("requires privileged settings edits for global runtime configuration", () => {
for (const legacyPath of [
"/admin/theme",
"/admin/favicon",
"/admin/translations/cms",
"/admin/translations/client",
"/admin/translations/emulator",
]) {
expect(contentMigrationEntries).toContainEqual(
expect.objectContaining({
legacyPath,
capabilities: expect.objectContaining({
mutate: [PERMS.SETTINGS_EDIT],
}),
auditRequirement: "PRIVILEGED_MUTATION",
}),
);
}
});
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,74 @@
import { readdirSync } from "node:fs";
import { join, relative } from "node:path";
import type { LegacyPage, LegacySurface } from "./types";
const pageFileName = "page.tsx";
function toLegacyPath(
surface: LegacySurface,
segments: readonly string[],
): string {
const routeSegments = segments
.filter((segment) => !(segment.startsWith("(") && segment.endsWith(")")))
.map((segment) =>
segment.replace(/^\[\.\.\.(.+)\]$/, ":$1*").replace(/^\[(.+)\]$/, ":$1"),
);
return `/${[surface, ...routeSegments].join("/")}`;
}
function discoverSurface(
rootDir: string,
surface: LegacySurface,
): LegacyPage[] {
const surfaceDirectory = join(rootDir, "src", "app", surface);
const pages: LegacyPage[] = [];
function walk(directory: string): void {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const entryPath = join(directory, entry.name);
if (entry.isDirectory()) {
walk(entryPath);
continue;
}
if (!entry.isFile() || entry.name !== pageFileName) continue;
const sourceFile = relative(rootDir, entryPath).replaceAll("\\", "/");
const routeSegments = relative(surfaceDirectory, directory)
.split(/[/\\]/)
.filter((segment) => segment && segment !== ".");
pages.push({
surface,
legacyPath: toLegacyPath(surface, routeSegments),
sourceFile,
});
}
}
walk(surfaceDirectory);
return pages;
}
export function discoverLegacyPages(rootDir = process.cwd()): LegacyPage[] {
return (["admin", "mod"] as const)
.flatMap((surface) => discoverSurface(rootDir, surface))
.sort((a, b) =>
`${a.surface}:${a.legacyPath}:${a.sourceFile}`.localeCompare(
`${b.surface}:${b.legacyPath}:${b.sourceFile}`,
),
);
}
export function ownedLegacyPages(
prefixes: readonly string[],
pages: readonly LegacyPage[] = discoverLegacyPages(),
): LegacyPage[] {
return pages.filter((page) =>
prefixes.some(
(prefix) =>
page.legacyPath === prefix || page.legacyPath.startsWith(`${prefix}/`),
),
);
}
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { ownedLegacyPages } from "./discover-legacy-pages";
import { economyMigrationEntries } from "./economy";
import { validateMigrationEntries } from "./validate-matrix";
const ECONOMY_PREFIXES = [
"/admin/catalog",
"/admin/items",
"/admin/shop",
"/admin/marketplace",
"/admin/transactions",
"/admin/vouchers",
"/admin/subscriptions",
"/admin/rare-values",
"/admin/badges",
"/admin/achievements",
"/admin/sounds",
"/admin/calendar",
] as const;
describe("economyMigrationEntries", () => {
it("covers every economy page without legacy-domain targets", () => {
const expected = ownedLegacyPages(ECONOMY_PREFIXES);
expect(validateMigrationEntries(expected, economyMigrationEntries)).toEqual(
[],
);
expect(
economyMigrationEntries.every(
(row) =>
row.targetPath === null ||
row.targetPath.startsWith("/admin/economy/"),
),
).toBe(true);
});
it("keeps every audited workflow planned without parity claims", () => {
expect(
economyMigrationEntries.every(
(row) => row.status === "PLANNED" && row.parityEvidence.length === 0,
),
).toBe(true);
});
it("requires privileged audit for economy mutations", () => {
const mutationRows = economyMigrationEntries.filter(
(row) => row.dependencies.mutations.length > 0,
);
expect(mutationRows.length).toBeGreaterThan(0);
expect(
mutationRows.every(
(row) => row.auditRequirement === "PRIVILEGED_MUTATION",
),
).toBe(true);
});
it("records the catalog search dependency on base items", () => {
const catalog = economyMigrationEntries.find(
(row) => row.legacyPath === "/admin/catalog",
);
expect(catalog?.dependencies.queries).toContain("ItemsBase");
});
it("marks the item collection localization as partial", () => {
const items = economyMigrationEntries.find(
(row) => row.legacyPath === "/admin/items",
);
expect(items?.localization).toBe("PARTIAL");
});
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { discoverLegacyPages } from "./discover-legacy-pages";
import { HOUSEKEEPING_MIGRATION_MATRIX } from "./matrix";
import { validateMigrationEntries } from "./validate-matrix";
describe("HOUSEKEEPING_MIGRATION_MATRIX", () => {
it("covers all 137 legacy pages exactly once", () => {
const discovered = discoverLegacyPages();
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(137);
expect(
validateMigrationEntries(discovered, HOUSEKEEPING_MIGRATION_MATRIX),
).toEqual([]);
});
it("contains no undecided evidence markers", () => {
expect(JSON.stringify(HOUSEKEEPING_MIGRATION_MATRIX)).not.toMatch(
/TBD|TODO|FIXME|UNCLASSIFIED/,
);
});
it("is sorted by legacy path", () => {
expect(HOUSEKEEPING_MIGRATION_MATRIX.map((row) => row.legacyPath)).toEqual(
[...HOUSEKEEPING_MIGRATION_MATRIX.map((row) => row.legacyPath)].sort(
(a, b) => a.localeCompare(b),
),
);
});
});
@@ -0,0 +1,15 @@
import { contentMigrationEntries } from "./content";
import { economyMigrationEntries } from "./economy";
import { hotelMigrationEntries } from "./hotel";
import { operationsMigrationEntries } from "./operations";
import { peopleMigrationEntries } from "./people";
import { systemMigrationEntries } from "./system";
export const HOUSEKEEPING_MIGRATION_MATRIX = [
...operationsMigrationEntries,
...peopleMigrationEntries,
...contentMigrationEntries,
...economyMigrationEntries,
...hotelMigrationEntries,
...systemMigrationEntries,
].sort((a, b) => a.legacyPath.localeCompare(b.legacyPath));
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import { operationsMigrationEntries } from "./operations";
describe("operationsMigrationEntries", () => {
it("covers the legacy dashboard once", () => {
expect(operationsMigrationEntries).toHaveLength(1);
expect(operationsMigrationEntries[0]).toMatchObject({
surface: "admin",
legacyPath: "/admin",
sourceFile: "src/app/admin/page.tsx",
targetDomain: "operations",
targetPath: "/admin/work",
decision: "REBUILD",
capabilities: { read: [PERMS.ADMIN_DASHBOARD], mutate: [] },
dependencies: {
queries: [
"users",
"active bans",
"website articles",
"staff activities",
],
mutations: [],
},
auditRequirement: "NONE",
localization: "COMPLETE",
accessibility: "PARTIAL",
requiredTests: ["integration", "e2e", "visual"],
parityEvidence: [],
status: "PLANNED",
notes: [
"Replace metric dashboard with capability-derived operational home",
],
});
});
});
@@ -0,0 +1,27 @@
import { PERMS } from "@/lib/permission-slugs";
import type { MigrationEntry } from "./types";
export const operationsMigrationEntries: readonly MigrationEntry[] = [
{
surface: "admin",
legacyPath: "/admin",
sourceFile: "src/app/admin/page.tsx",
targetDomain: "operations",
targetPath: "/admin/work",
decision: "REBUILD",
capabilities: { read: [PERMS.ADMIN_DASHBOARD], mutate: [] },
dependencies: {
queries: ["users", "active bans", "website articles", "staff activities"],
mutations: [],
},
auditRequirement: "NONE",
localization: "COMPLETE",
accessibility: "PARTIAL",
requiredTests: ["integration", "e2e", "visual"],
parityEvidence: [],
status: "PLANNED",
notes: [
"Replace metric dashboard with capability-derived operational home",
],
},
];
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { discoverLegacyPages } from "./discover-legacy-pages";
import { operationsMigrationEntries } from "./operations";
import { peopleMigrationEntries } from "./people";
import { validateMigrationEntries } from "./validate-matrix";
const PEOPLE_PREFIXES = [
"/admin/users",
"/admin/online",
"/admin/guilds",
"/admin/applications",
"/admin/teams",
"/admin/bans",
"/admin/ip",
"/admin/vpn",
"/admin/wordfilter",
"/admin/moderation",
"/admin/tickets",
"/admin/help-tickets",
"/mod",
] as const;
describe("peopleMigrationEntries", () => {
it("merges every mod page into a people workflow", () => {
const modRows = peopleMigrationEntries.filter(
(row) => row.surface === "mod",
);
expect(modRows).toHaveLength(13);
expect(modRows.every((row) => row.decision === "MERGE")).toBe(true);
expect(
modRows.every((row) => row.targetPath?.startsWith("/admin/people/")),
).toBe(true);
});
it("merges duplicate user detail and edit aliases", () => {
for (const legacyPath of ["/admin/users/:id", "/admin/users/:id/edit"]) {
expect(peopleMigrationEntries).toContainEqual(
expect.objectContaining({ legacyPath, decision: "MERGE" }),
);
}
});
it("keeps planned rows free of unverified parity claims", () => {
expect(
peopleMigrationEntries.every(
(row) => row.status === "PLANNED" && row.parityEvidence.length === 0,
),
).toBe(true);
});
it("covers every Operations and People legacy page exactly once", () => {
const expected = discoverLegacyPages().filter(
(page) =>
page.legacyPath === "/admin" ||
PEOPLE_PREFIXES.some(
(prefix) =>
page.legacyPath === prefix ||
page.legacyPath.startsWith(`${prefix}/`),
),
);
expect(
validateMigrationEntries(expected, [
...operationsMigrationEntries,
...peopleMigrationEntries,
]),
).toEqual([]);
});
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,40 @@
export const HOUSEKEEPING_DOMAIN_IDS = [
"operations",
"people",
"content",
"economy",
"hotel",
"system",
] as const;
export type HousekeepingDomainId = (typeof HOUSEKEEPING_DOMAIN_IDS)[number];
export type LegacySurface = "admin" | "mod";
export type MigrationDecision = "REHOST" | "REBUILD" | "MERGE" | "REMOVE";
export type AuditRequirement = "NONE" | "MUTATION" | "PRIVILEGED_MUTATION";
export type AuditState = "UNAUDITED" | "PARTIAL" | "COMPLETE";
export type MigrationStatus =
| "PLANNED"
| "IN_PROGRESS"
| "VERIFIED"
| "REMOVED";
export interface LegacyPage {
surface: LegacySurface;
legacyPath: string;
sourceFile: string;
}
export interface MigrationEntry extends LegacyPage {
targetDomain: HousekeepingDomainId;
targetPath: string | null;
decision: MigrationDecision;
capabilities: { read: readonly string[]; mutate: readonly string[] };
dependencies: { queries: readonly string[]; mutations: readonly string[] };
auditRequirement: AuditRequirement;
localization: AuditState;
accessibility: AuditState;
requiredTests: readonly ("unit" | "integration" | "e2e" | "visual")[];
parityEvidence: readonly string[];
status: MigrationStatus;
notes: readonly string[];
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+9 -2
View File
@@ -2,7 +2,12 @@ import { readdirSync, readFileSync } from "node:fs";
import { join, relative } from "node:path";
import { describe, expect, it } from "vitest";
const ROOTS = ["src/app/admin", "src/components/admin"];
const ROOTS = [
"src/app/admin",
"src/components/admin",
"src/app/admin-next",
"src/features/housekeeping",
];
const GRAPHICAL_ALLOWLIST = [
"src/app/admin/favicon/favicon-generator.tsx",
"src/app/admin/import/clone/import-clone-client.tsx",
@@ -34,7 +39,9 @@ function sourceFiles(directory: string): string[] {
const path = join(directory, entry.name);
return entry.isDirectory()
? sourceFiles(path)
: /\.(?:ts|tsx)$/.test(entry.name)
: /\.(?:ts|tsx)$/.test(entry.name) &&
!entry.name.endsWith(".test.ts") &&
!entry.name.endsWith(".test.tsx")
? [path]
: [];
});
+57
View File
@@ -3256,6 +3256,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
@@ -3261,6 +3261,63 @@
"failed": "Failed"
}
}
},
"housekeeping": {
"preview": {
"badge": "Anteprima della struttura",
"commandDisabled": "Ricerca e comandi saranno abilitati in un sottoprogetto successivo.",
"backToSite": "Torna al sito"
},
"navigation": {
"skipToContent": "Vai al contenuto",
"primary": "Aree 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 in corso",
"description": "Stiamo preparando gli strumenti di 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."
}
}
}
}
}