Merge pull request 'feat: add housekeeping inventory foundation' (#51) from codex/housekeeping-foundation into main
Reviewed-on: #51
This commit was merged in pull request #51.
This commit is contained in:
commit
d5eadeb3a7
71 files changed
+10209
-3
No files matched your search
@@ -17,6 +17,8 @@ NODE_ENV=production
|
|||||||
PORT=3002
|
PORT=3002
|
||||||
NEXT_TELEMETRY_DISABLED=1
|
NEXT_TELEMETRY_DISABLED=1
|
||||||
UV_THREADPOOL_SIZE=16
|
UV_THREADPOOL_SIZE=16
|
||||||
|
# Non-production preview only; production always returns 404.
|
||||||
|
HOUSEKEEPING_NEXT_PREVIEW_ENABLED=false
|
||||||
|
|
||||||
# --- HOTEL & URLS ---
|
# --- HOTEL & URLS ---
|
||||||
HOTEL_NAME=EPIC WEB CONTROL
|
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
@@ -21,7 +21,9 @@
|
|||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "tsx scripts/apply-migrations.ts",
|
"db:migrate": "tsx scripts/apply-migrations.ts",
|
||||||
"db:migrate:status": "tsx scripts/apply-migrations.ts --status",
|
"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": {
|
"lint-staged": {
|
||||||
"*.{js,ts,jsx,tsx,json}": "biome check --write --no-errors-on-unmatched"
|
"*.{js,ts,jsx,tsx,json}": "biome check --write --no-errors-on-unmatched"
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -9,6 +9,10 @@ const schema = z
|
|||||||
NODE_ENV: z
|
NODE_ENV: z
|
||||||
.enum(["development", "test", "production"])
|
.enum(["development", "test", "production"])
|
||||||
.default("development"),
|
.default("development"),
|
||||||
|
HOUSEKEEPING_NEXT_PREVIEW_ENABLED: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((value) => value === "true" || value === "1"),
|
||||||
DATABASE_URL: z.string().url(),
|
DATABASE_URL: z.string().url(),
|
||||||
DATABASE_POOL_SIZE: z.coerce.number().int().positive().default(10),
|
DATABASE_POOL_SIZE: z.coerce.number().int().positive().default(10),
|
||||||
DATABASE_IDLE_TIMEOUT_MS: z.coerce
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -2,7 +2,12 @@ import { readdirSync, readFileSync } from "node:fs";
|
|||||||
import { join, relative } from "node:path";
|
import { join, relative } from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
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 = [
|
const GRAPHICAL_ALLOWLIST = [
|
||||||
"src/app/admin/favicon/favicon-generator.tsx",
|
"src/app/admin/favicon/favicon-generator.tsx",
|
||||||
"src/app/admin/import/clone/import-clone-client.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);
|
const path = join(directory, entry.name);
|
||||||
return entry.isDirectory()
|
return entry.isDirectory()
|
||||||
? sourceFiles(path)
|
? sourceFiles(path)
|
||||||
: /\.(?:ts|tsx)$/.test(entry.name)
|
: /\.(?:ts|tsx)$/.test(entry.name) &&
|
||||||
|
!entry.name.endsWith(".test.ts") &&
|
||||||
|
!entry.name.endsWith(".test.tsx")
|
||||||
? [path]
|
? [path]
|
||||||
: [];
|
: [];
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3256,6 +3256,63 @@
|
|||||||
"failed": "Failed"
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3261,6 +3261,63 @@
|
|||||||
"failed": "Failed"
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user