feat: add gated housekeeping foundation preview
This commit is contained in:
1 parent
cc241f0b7e
commit
d19ba88005
9 files changed
+516
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
.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,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
Reference in new issue
Block a user