feat(housekeeping): unify complete rebuild in gated preview
This commit is contained in:
1 parent
4ad007d241
commit
43470ebf82
402 files changed
+78313
-1663
No files matched your search
@@ -0,0 +1,244 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { registerHousekeepingCommand } from "@/features/housekeeping/foundation/commands/registry";
|
||||
|
||||
vi.mock(
|
||||
"@/features/housekeeping/foundation/commands/registry",
|
||||
async (importOriginal) => ({
|
||||
...(await importOriginal<
|
||||
typeof import("@/features/housekeeping/foundation/commands/registry")
|
||||
>()),
|
||||
sealHousekeepingCommandRegistry: sealRegistryMock,
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@/features/housekeeping/commands", () => ({
|
||||
housekeepingCommandRegistryReady: true,
|
||||
}));
|
||||
|
||||
import {
|
||||
anyCapability,
|
||||
ok,
|
||||
} from "@/features/housekeeping/foundation/contracts";
|
||||
import type { AuditEntry } from "@/lib/services/audit";
|
||||
|
||||
const {
|
||||
auditEntries,
|
||||
auditWriteMock,
|
||||
commandExecutions,
|
||||
context,
|
||||
getContextMock,
|
||||
getIpMock,
|
||||
rateLimitCalls,
|
||||
sealRegistryMock,
|
||||
} = vi.hoisted(() => ({
|
||||
auditEntries: [] as AuditEntry[],
|
||||
auditWriteMock: vi.fn(),
|
||||
commandExecutions: [] as string[],
|
||||
context: {
|
||||
actor: { id: 71, username: "server-operator", rank: 4 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug: string) => slug === "admin.settings.edit",
|
||||
hasAny: (...slugs: string[]) => slugs.includes("admin.settings.edit"),
|
||||
hasAll: (...slugs: string[]) =>
|
||||
slugs.every((slug) => slug === "admin.settings.edit"),
|
||||
},
|
||||
getContextMock: vi.fn(),
|
||||
getIpMock: vi.fn(),
|
||||
rateLimitCalls: [] as Array<[string, number, number]>,
|
||||
sealRegistryMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
|
||||
getHousekeepingCapabilityContext: getContextMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/services/audit", () => ({
|
||||
housekeepingAuditWriter: { write: auditWriteMock },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
clientIp: getIpMock,
|
||||
rateLimit: async (key: string, attempts: number, windowMs: number) => {
|
||||
rateLimitCalls.push([key, attempts, windowMs]);
|
||||
return { ok: true, retryAfter: 0 };
|
||||
},
|
||||
}));
|
||||
|
||||
import { executeHousekeepingCommand } from "./housekeeping-command";
|
||||
|
||||
registerHousekeepingCommand({
|
||||
id: "system.server-action.serializable",
|
||||
owner: "system",
|
||||
risk: "safe",
|
||||
capability: anyCapability("admin.settings.edit"),
|
||||
input: z.object({ value: z.string() }),
|
||||
requiresReason: false,
|
||||
rateLimit: { attempts: 5, windowMs: 120_000 },
|
||||
execute: async (commandContext, input) => {
|
||||
commandExecutions.push(input.value);
|
||||
return ok(
|
||||
{
|
||||
value: input.value,
|
||||
actorId: commandContext.capability.actor.id,
|
||||
ipAddress: commandContext.ipAddress,
|
||||
},
|
||||
commandContext.correlationId,
|
||||
input.value === "partial"
|
||||
? {
|
||||
status: "partial",
|
||||
external: "failed",
|
||||
audit: "persisted",
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
auditEntries.length = 0;
|
||||
commandExecutions.length = 0;
|
||||
rateLimitCalls.length = 0;
|
||||
getContextMock.mockReset().mockResolvedValue(context);
|
||||
getIpMock.mockReset().mockResolvedValue("203.0.113.7");
|
||||
sealRegistryMock.mockReset();
|
||||
auditWriteMock.mockReset().mockImplementation(async (entry: AuditEntry) => {
|
||||
auditEntries.push({ ...entry });
|
||||
});
|
||||
});
|
||||
|
||||
describe("executeHousekeepingCommand", () => {
|
||||
it("accepts a plain request and derives all policy metadata server-side", async () => {
|
||||
const result = await executeHousekeepingCommand({
|
||||
commandId: "system.server-action.serializable",
|
||||
input: { value: "saved" },
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
data: {
|
||||
value: "saved",
|
||||
actorId: 71,
|
||||
ipAddress: "203.0.113.7",
|
||||
},
|
||||
});
|
||||
expect(rateLimitCalls).toEqual([
|
||||
[
|
||||
"housekeeping-command:71:203.0.113.7:system.server-action.serializable",
|
||||
5,
|
||||
120_000,
|
||||
],
|
||||
]);
|
||||
expect(auditEntries).toMatchObject([
|
||||
{
|
||||
userId: 71,
|
||||
action: "system.server-action.serializable",
|
||||
target: "system",
|
||||
domain: "system",
|
||||
ipAddress: "203.0.113.7",
|
||||
outcome: "success",
|
||||
},
|
||||
]);
|
||||
expect(auditEntries[0]?.correlationId).toBe(result.correlationId);
|
||||
expect(sealRegistryMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves one returned partial completion and its correlation through the real action dispatcher", async () => {
|
||||
const result = await executeHousekeepingCommand({
|
||||
commandId: "system.server-action.serializable",
|
||||
input: { value: "partial" },
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
data: { value: "partial" },
|
||||
completion: {
|
||||
status: "partial",
|
||||
external: "failed",
|
||||
audit: "persisted",
|
||||
},
|
||||
});
|
||||
expect(auditEntries).toHaveLength(1);
|
||||
expect(auditEntries[0]).toMatchObject({
|
||||
outcome: "partial",
|
||||
correlationId: result.correlationId,
|
||||
});
|
||||
expect(() => JSON.stringify(result)).not.toThrow();
|
||||
});
|
||||
it("strictly rejects spoofed server-owned metadata before execution", async () => {
|
||||
const result = await executeHousekeepingCommand({
|
||||
commandId: "system.server-action.serializable",
|
||||
input: { value: "forged" },
|
||||
risk: "sensitive",
|
||||
owner: "people",
|
||||
capability: { mode: "any", slugs: ["forged.permission"] },
|
||||
actor: { id: 999 },
|
||||
ipAddress: "198.51.100.9",
|
||||
rateLimit: { attempts: 999, windowMs: 1 },
|
||||
audit: { action: "forged.action", target: "forged-target" },
|
||||
} as never);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "VALIDATION" },
|
||||
});
|
||||
expect(commandExecutions).toEqual([]);
|
||||
expect(rateLimitCalls).toEqual([]);
|
||||
expect(auditEntries).toMatchObject([
|
||||
{
|
||||
userId: 71,
|
||||
action: "housekeeping.command.dispatch",
|
||||
target: "request-envelope",
|
||||
ipAddress: "203.0.113.7",
|
||||
outcome: "denied",
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(auditEntries)).not.toContain("forged");
|
||||
});
|
||||
|
||||
it("sanitizes server context acquisition failures into typed results", async () => {
|
||||
getContextMock.mockRejectedValue(
|
||||
new Error("session database secret exposed"),
|
||||
);
|
||||
|
||||
const result = await executeHousekeepingCommand({
|
||||
commandId: "system.server-action.serializable",
|
||||
input: { value: "blocked" },
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "INTERNAL", messageKey: "errors.housekeeping.internal" },
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain("secret exposed");
|
||||
expect(commandExecutions).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns one truthful partial completion when outcome audit persistence fails", async () => {
|
||||
auditWriteMock.mockImplementation(async (entry: AuditEntry) => {
|
||||
if (entry.outcome === "success") {
|
||||
throw new Error("success audit unavailable");
|
||||
}
|
||||
auditEntries.push({ ...entry });
|
||||
});
|
||||
|
||||
const result = await executeHousekeepingCommand({
|
||||
commandId: "system.server-action.serializable",
|
||||
input: { value: "changed" },
|
||||
});
|
||||
|
||||
expect(commandExecutions).toEqual(["changed"]);
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
data: { value: "changed" },
|
||||
completion: {
|
||||
status: "partial",
|
||||
external: "not-required",
|
||||
audit: "persisted",
|
||||
},
|
||||
});
|
||||
expect(auditEntries.map((entry) => entry.outcome)).toEqual(["partial"]);
|
||||
expect(auditEntries[0]?.correlationId).toBe(result.correlationId);
|
||||
expect(() => JSON.stringify(result)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
"use server";
|
||||
|
||||
import "@/features/housekeeping/commands";
|
||||
import { dispatchHousekeepingCommand } from "@/features/housekeeping/foundation/commands/dispatcher";
|
||||
import {
|
||||
type HousekeepingResult,
|
||||
mapUnknownError,
|
||||
} from "@/features/housekeeping/foundation/contracts";
|
||||
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { housekeepingAuditWriter } from "@/lib/services/audit";
|
||||
|
||||
export async function executeHousekeepingCommand(
|
||||
request: unknown,
|
||||
): Promise<HousekeepingResult<unknown>> {
|
||||
try {
|
||||
const [context, ipAddress] = await Promise.all([
|
||||
getHousekeepingCapabilityContext(),
|
||||
clientIp(),
|
||||
]);
|
||||
|
||||
return await dispatchHousekeepingCommand(request, {
|
||||
context,
|
||||
ipAddress,
|
||||
audit: housekeepingAuditWriter,
|
||||
rateLimit: async (key, attempts, windowMs) =>
|
||||
(await rateLimit(key, attempts, windowMs)).ok,
|
||||
});
|
||||
} catch (error) {
|
||||
return mapUnknownError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { HousekeepingCapabilityContext } from "@/features/housekeeping/foundation/contracts";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
|
||||
const { getContextMock, loadInboxMock } = vi.hoisted(() => ({
|
||||
getContextMock: vi.fn(),
|
||||
loadInboxMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
|
||||
getHousekeepingCapabilityContext: getContextMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/features/housekeeping/foundation/inbox/inbox-service", () => ({
|
||||
loadHousekeepingInbox: loadInboxMock,
|
||||
}));
|
||||
|
||||
import { executeHousekeepingInbox } from "./housekeeping-inbox";
|
||||
|
||||
const context: HousekeepingCapabilityContext = {
|
||||
actor: { id: 42, username: "operator", rank: 7 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => slug === PERMS.USERS_VIEW,
|
||||
hasAny: (...slugs) => slugs.includes(PERMS.USERS_VIEW),
|
||||
hasAll: (...slugs) => slugs.every((slug) => slug === PERMS.USERS_VIEW),
|
||||
};
|
||||
|
||||
describe("housekeeping inbox action", () => {
|
||||
beforeEach(() => {
|
||||
getContextMock.mockReset().mockResolvedValue(context);
|
||||
loadInboxMock.mockReset().mockResolvedValue({
|
||||
items: [],
|
||||
errors: [],
|
||||
correlationId: "inbox-action",
|
||||
});
|
||||
});
|
||||
|
||||
it("binds inbox composition to a fresh server capability context", async () => {
|
||||
const response = await executeHousekeepingInbox();
|
||||
expect(getContextMock).toHaveBeenCalledOnce();
|
||||
expect(loadInboxMock).toHaveBeenCalledWith(context);
|
||||
expect(response.correlationId).toBe("inbox-action");
|
||||
});
|
||||
|
||||
it("returns a typed partial envelope when the boundary throws", async () => {
|
||||
loadInboxMock.mockRejectedValueOnce(new Error("inbox unavailable"));
|
||||
const response = await executeHousekeepingInbox();
|
||||
expect(response.items).toEqual([]);
|
||||
expect(response.errors).toEqual([{ sourceId: "inbox", code: "INTERNAL" }]);
|
||||
expect(response.correlationId).toEqual(expect.any(String));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
"use server";
|
||||
|
||||
import { createCorrelationId } from "@/features/housekeeping/foundation/correlation";
|
||||
import {
|
||||
type HousekeepingInboxResponse,
|
||||
loadHousekeepingInbox,
|
||||
} from "@/features/housekeeping/foundation/inbox/inbox-service";
|
||||
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
|
||||
|
||||
function failedInbox(): HousekeepingInboxResponse {
|
||||
return {
|
||||
items: [],
|
||||
errors: [{ sourceId: "inbox", code: "INTERNAL" }],
|
||||
correlationId: createCorrelationId(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeHousekeepingInbox(): Promise<HousekeepingInboxResponse> {
|
||||
try {
|
||||
const context = await getHousekeepingCapabilityContext();
|
||||
return await loadHousekeepingInbox(context);
|
||||
} catch {
|
||||
return failedInbox();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
|
||||
getHousekeepingCapabilityContext: vi.fn(),
|
||||
}));
|
||||
|
||||
const preferenceRepository = vi.hoisted(() => ({
|
||||
read: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/housekeeping-preferences-repository", () => ({
|
||||
housekeepingPreferencesRepository: preferenceRepository,
|
||||
}));
|
||||
|
||||
import { defaultHousekeepingPreferences } from "@/features/housekeeping/foundation/preferences/schema";
|
||||
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
|
||||
import {
|
||||
loadHousekeepingPreferences,
|
||||
saveHousekeepingPreferences,
|
||||
} from "./housekeeping-preferences";
|
||||
|
||||
const allowedContext = {
|
||||
actor: { id: 42, username: "operator", rank: 0 },
|
||||
isSuperAdmin: false,
|
||||
has: () => true,
|
||||
hasAny: () => true,
|
||||
hasAll: () => true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getHousekeepingCapabilityContext).mockResolvedValue(allowedContext);
|
||||
preferenceRepository.read.mockResolvedValue(defaultHousekeepingPreferences());
|
||||
});
|
||||
|
||||
describe("housekeeping preference actions", () => {
|
||||
it("does not expose dependency or user identity parameters to callers", () => {
|
||||
expect(loadHousekeepingPreferences).toHaveLength(0);
|
||||
expect(saveHousekeepingPreferences).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("publishes exact action signatures without caller-controlled dependencies", () => {
|
||||
expectTypeOf<
|
||||
Parameters<typeof loadHousekeepingPreferences>
|
||||
>().toEqualTypeOf<[]>();
|
||||
expectTypeOf<
|
||||
Parameters<typeof saveHousekeepingPreferences>
|
||||
>().toEqualTypeOf<[input: unknown]>();
|
||||
});
|
||||
|
||||
it("derives the read owner from the server capability context", async () => {
|
||||
const result = await loadHousekeepingPreferences();
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(preferenceRepository.read).toHaveBeenCalledWith(42);
|
||||
});
|
||||
|
||||
it("rejects a denied operator without reading or writing preferences", async () => {
|
||||
vi.mocked(getHousekeepingCapabilityContext).mockResolvedValueOnce({
|
||||
...allowedContext,
|
||||
hasAny: () => false,
|
||||
});
|
||||
|
||||
const result = await saveHousekeepingPreferences(
|
||||
defaultHousekeepingPreferences(),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: { code: "FORBIDDEN" } });
|
||||
expect(preferenceRepository.read).not.toHaveBeenCalled();
|
||||
expect(preferenceRepository.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reconciles input before persisting or returning it", async () => {
|
||||
const value = {
|
||||
...defaultHousekeepingPreferences(),
|
||||
pinnedRouteIds: ["removed.route"],
|
||||
pinnedCommandIds: ["removed.command"],
|
||||
};
|
||||
|
||||
const result = await saveHousekeepingPreferences(value);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
data: { pinnedRouteIds: [], pinnedCommandIds: [] },
|
||||
});
|
||||
expect(preferenceRepository.upsert).toHaveBeenCalledWith(
|
||||
42,
|
||||
expect.objectContaining({ pinnedRouteIds: [], pinnedCommandIds: [] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns a validation result before an invalid payload reaches persistence", async () => {
|
||||
const result = await saveHousekeepingPreferences({ schemaVersion: 2 });
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: { code: "VALIDATION" } });
|
||||
expect(preferenceRepository.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
"use server";
|
||||
|
||||
import { authorizeHousekeeping } from "@/features/housekeeping/foundation/authorization";
|
||||
import {
|
||||
anyCapability,
|
||||
fail,
|
||||
type HousekeepingResult,
|
||||
ok,
|
||||
} from "@/features/housekeeping/foundation/contracts";
|
||||
import { createCorrelationId } from "@/features/housekeeping/foundation/correlation";
|
||||
import { reconcilePreferences } from "@/features/housekeeping/foundation/preferences/reconcile";
|
||||
import {
|
||||
type HousekeepingPreferences,
|
||||
housekeepingPreferencesSchema,
|
||||
} from "@/features/housekeeping/foundation/preferences/schema";
|
||||
import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
|
||||
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
|
||||
import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests";
|
||||
import { housekeepingPreferencesRepository } from "@/lib/housekeeping-preferences-repository";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
|
||||
const preferencesCapability = anyCapability(PERMS.ADMIN_DASHBOARD);
|
||||
const housekeepingRegistry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
|
||||
|
||||
export async function loadHousekeepingPreferences(): Promise<
|
||||
HousekeepingResult<HousekeepingPreferences>
|
||||
> {
|
||||
const context = await getHousekeepingCapabilityContext();
|
||||
const authorization = authorizeHousekeeping(context, preferencesCapability);
|
||||
if (!authorization.ok) return authorization;
|
||||
|
||||
const correlationId = createCorrelationId();
|
||||
try {
|
||||
const stored = await housekeepingPreferencesRepository.read(
|
||||
context.actor.id,
|
||||
);
|
||||
return ok(
|
||||
reconcilePreferences(stored, housekeepingRegistry, context),
|
||||
correlationId,
|
||||
);
|
||||
} catch {
|
||||
return fail(
|
||||
"INTERNAL",
|
||||
"errors.housekeeping.preferences.read",
|
||||
correlationId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveHousekeepingPreferences(
|
||||
input: unknown,
|
||||
): Promise<HousekeepingResult<HousekeepingPreferences>> {
|
||||
const context = await getHousekeepingCapabilityContext();
|
||||
const authorization = authorizeHousekeeping(context, preferencesCapability);
|
||||
if (!authorization.ok) return authorization;
|
||||
|
||||
const correlationId = createCorrelationId();
|
||||
const parsed = housekeepingPreferencesSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return fail(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.preferences.invalid",
|
||||
correlationId,
|
||||
);
|
||||
}
|
||||
|
||||
const reconciled = reconcilePreferences(
|
||||
parsed.data,
|
||||
housekeepingRegistry,
|
||||
context,
|
||||
);
|
||||
try {
|
||||
await housekeepingPreferencesRepository.upsert(
|
||||
context.actor.id,
|
||||
reconciled,
|
||||
);
|
||||
return ok(reconciled, correlationId);
|
||||
} catch {
|
||||
return fail(
|
||||
"INTERNAL",
|
||||
"errors.housekeeping.preferences.save",
|
||||
correlationId,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { getContextMock, loadRecentMock, recordVisitMock } = vi.hoisted(() => ({
|
||||
getContextMock: vi.fn(),
|
||||
loadRecentMock: vi.fn(),
|
||||
recordVisitMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
|
||||
getHousekeepingCapabilityContext: getContextMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/housekeeping-recent-work", () => ({
|
||||
loadHousekeepingRecentWork: loadRecentMock,
|
||||
recordHousekeepingRouteVisit: recordVisitMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
executeHousekeepingRecent,
|
||||
recordHousekeepingRouteVisitAction,
|
||||
} from "./housekeeping-recent";
|
||||
|
||||
const context = {
|
||||
actor: { id: 42, username: "operator", rank: 7 },
|
||||
isSuperAdmin: false,
|
||||
has: () => true,
|
||||
hasAny: () => true,
|
||||
hasAll: () => true,
|
||||
};
|
||||
|
||||
describe("housekeeping recent actions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getContextMock.mockResolvedValue(context);
|
||||
loadRecentMock.mockResolvedValue({
|
||||
ok: true,
|
||||
data: [],
|
||||
correlationId: "recent-action",
|
||||
});
|
||||
recordVisitMock.mockResolvedValue({
|
||||
ok: true,
|
||||
data: { routeId: "people.users" },
|
||||
correlationId: "visit-action",
|
||||
});
|
||||
});
|
||||
|
||||
it("binds load and visit recording to a fresh server capability context", async () => {
|
||||
await expect(executeHousekeepingRecent()).resolves.toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
await expect(
|
||||
recordHousekeepingRouteVisitAction("people.users"),
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
expect(loadRecentMock).toHaveBeenCalledWith(context);
|
||||
expect(recordVisitMock).toHaveBeenCalledWith("people.users", context);
|
||||
});
|
||||
|
||||
it("rejects a forged route identifier before resolving server context", async () => {
|
||||
const result = await recordHousekeepingRouteVisitAction({
|
||||
routeId: "people.users",
|
||||
});
|
||||
expect(result).toMatchObject({ ok: false, error: { code: "VALIDATION" } });
|
||||
expect(getContextMock).not.toHaveBeenCalled();
|
||||
expect(recordVisitMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps unexpected boundary failures to typed internal results", async () => {
|
||||
loadRecentMock.mockRejectedValueOnce(new Error("audit unavailable"));
|
||||
const result = await executeHousekeepingRecent();
|
||||
expect(result).toMatchObject({ ok: false, error: { code: "INTERNAL" } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
"use server";
|
||||
|
||||
import {
|
||||
fail,
|
||||
type HousekeepingResult,
|
||||
mapUnknownError,
|
||||
} from "@/features/housekeeping/foundation/contracts";
|
||||
import { createCorrelationId } from "@/features/housekeeping/foundation/correlation";
|
||||
import type { HousekeepingRecentItem } from "@/features/housekeeping/foundation/recent/recent-work";
|
||||
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
|
||||
import {
|
||||
loadHousekeepingRecentWork,
|
||||
recordHousekeepingRouteVisit,
|
||||
} from "@/lib/housekeeping-recent-work";
|
||||
|
||||
export async function executeHousekeepingRecent(): Promise<
|
||||
HousekeepingResult<readonly HousekeepingRecentItem[]>
|
||||
> {
|
||||
try {
|
||||
const context = await getHousekeepingCapabilityContext();
|
||||
return await loadHousekeepingRecentWork(context);
|
||||
} catch (error) {
|
||||
return mapUnknownError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function recordHousekeepingRouteVisitAction(
|
||||
routeId: unknown,
|
||||
): Promise<HousekeepingResult<HousekeepingRecentItem>> {
|
||||
if (
|
||||
typeof routeId !== "string" ||
|
||||
!routeId.trim() ||
|
||||
routeId !== routeId.trim() ||
|
||||
routeId.length > 128
|
||||
) {
|
||||
return fail(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.recent.invalidRoute",
|
||||
createCorrelationId(),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const context = await getHousekeepingCapabilityContext();
|
||||
return await recordHousekeepingRouteVisit(routeId, context);
|
||||
} catch (error) {
|
||||
return mapUnknownError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { HousekeepingCapabilityContext } from "@/features/housekeeping/foundation/contracts";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
|
||||
const { getContextMock, searchMock } = vi.hoisted(() => ({
|
||||
getContextMock: vi.fn(),
|
||||
searchMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/housekeeping/commands", () => ({
|
||||
housekeepingCommandRegistryReady: true,
|
||||
}));
|
||||
|
||||
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
|
||||
getHousekeepingCapabilityContext: getContextMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/features/housekeeping/foundation/search/search-service", () => ({
|
||||
searchHousekeeping: searchMock,
|
||||
}));
|
||||
|
||||
import { executeHousekeepingSearch } from "./housekeeping-search";
|
||||
|
||||
const context: HousekeepingCapabilityContext = {
|
||||
actor: { id: 42, username: "operator", rank: 7 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => slug === PERMS.USERS_VIEW,
|
||||
hasAny: (...slugs) => slugs.includes(PERMS.USERS_VIEW),
|
||||
hasAll: (...slugs) => slugs.every((slug) => slug === PERMS.USERS_VIEW),
|
||||
};
|
||||
|
||||
describe("housekeeping search action", () => {
|
||||
beforeEach(() => {
|
||||
getContextMock.mockReset().mockResolvedValue(context);
|
||||
searchMock.mockReset().mockResolvedValue({
|
||||
navigation: [],
|
||||
commands: [],
|
||||
entities: [],
|
||||
errors: [],
|
||||
correlationId: "action-search",
|
||||
});
|
||||
});
|
||||
|
||||
it("binds search to the fresh server capability context", async () => {
|
||||
const result = await executeHousekeepingSearch(" users ");
|
||||
expect(searchMock).toHaveBeenCalledWith(" users ", context);
|
||||
expect(result.correlationId).toBe("action-search");
|
||||
});
|
||||
|
||||
it("rejects forged non-string terms without invoking dependencies", async () => {
|
||||
const result = await executeHousekeepingSearch({ term: "users" });
|
||||
expect(getContextMock).not.toHaveBeenCalled();
|
||||
expect(searchMock).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({
|
||||
errors: [{ providerId: "search", code: "VALIDATION" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
"use server";
|
||||
|
||||
import type { HousekeepingErrorCode } from "@/features/housekeeping/foundation/contracts";
|
||||
import { createCorrelationId } from "@/features/housekeeping/foundation/correlation";
|
||||
import {
|
||||
type HousekeepingSearchResponse,
|
||||
searchHousekeeping,
|
||||
} from "@/features/housekeeping/foundation/search/search-service";
|
||||
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
|
||||
|
||||
function failedSearch(code: HousekeepingErrorCode): HousekeepingSearchResponse {
|
||||
return {
|
||||
navigation: [],
|
||||
commands: [],
|
||||
entities: [],
|
||||
errors: [{ providerId: "search", code }],
|
||||
correlationId: createCorrelationId(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeHousekeepingSearch(
|
||||
term: unknown,
|
||||
): Promise<HousekeepingSearchResponse> {
|
||||
if (typeof term !== "string") return failedSearch("VALIDATION");
|
||||
try {
|
||||
await import("@/features/housekeeping/commands");
|
||||
const context = await getHousekeepingCapabilityContext();
|
||||
return await searchHousekeeping(term, context);
|
||||
} catch {
|
||||
return failedSearch("INTERNAL");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { GET } from "./route";
|
||||
|
||||
vi.mock("node:fs", () => ({ existsSync: vi.fn() }));
|
||||
vi.mock("node:fs/promises", () => ({ readFile: vi.fn() }));
|
||||
vi.mock("@/lib/media-storage", async () => {
|
||||
const path = await import("node:path");
|
||||
const mediaRoot = path.resolve("media-fixture");
|
||||
return {
|
||||
MEDIA_ROOT: mediaRoot,
|
||||
resolveMediaPath: vi.fn((name: string) => path.join(mediaRoot, name)),
|
||||
};
|
||||
});
|
||||
|
||||
describe("GET /api/media/[...path]", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
vi.mocked(readFile).mockResolvedValue(Buffer.from([0, 0, 1, 0]));
|
||||
});
|
||||
|
||||
it("serves a stored genuine ICO with the canonical MIME and nosniff", async () => {
|
||||
const response = await GET(
|
||||
new Request("http://localhost/api/media/favicon/site.ico"),
|
||||
{
|
||||
params: Promise.resolve({ path: ["favicon", "site.ico"] }),
|
||||
},
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("image/x-icon");
|
||||
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
expect(readFile).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,16 @@ import path from "node:path";
|
||||
import { NextResponse } from "next/server";
|
||||
import { MEDIA_ROOT, resolveMediaPath } from "@/lib/media-storage";
|
||||
|
||||
const ALLOWED_EXT = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp"];
|
||||
const ALLOWED_EXT = [
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".gif",
|
||||
".webp",
|
||||
".svg",
|
||||
".bmp",
|
||||
".ico",
|
||||
];
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
@@ -41,12 +50,20 @@ export async function GET(
|
||||
".webp": "image/webp",
|
||||
".svg": "image/svg+xml",
|
||||
".bmp": "image/bmp",
|
||||
".ico": "image/x-icon",
|
||||
};
|
||||
|
||||
return new NextResponse(bytes, {
|
||||
headers: {
|
||||
// eslint-disable-next-line security/detect-object-injection -- ext validated against ALLOWED_EXT
|
||||
"Content-Type": mime[ext] ?? "application/octet-stream",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
...(ext === ".svg"
|
||||
? {
|
||||
"Content-Security-Policy":
|
||||
"sandbox; default-src 'none'; style-src 'unsafe-inline'",
|
||||
}
|
||||
: {}),
|
||||
"Cache-Control": "public, max-age=3600, must-revalidate",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -8,20 +8,9 @@ import { getHousekeepingCapabilityContext } from "@/features/housekeeping/founda
|
||||
import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests";
|
||||
import { HOUSEKEEPING_ROUTE_HANDLERS } from "@/features/housekeeping/route-handlers";
|
||||
|
||||
const MESSAGE_PREFIX = "pages.housekeeping.";
|
||||
|
||||
type HousekeepingSearchParams = Readonly<
|
||||
Record<string, string | readonly string[] | undefined>
|
||||
>;
|
||||
|
||||
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 HousekeepingDomainPage({
|
||||
params,
|
||||
searchParams,
|
||||
@@ -39,9 +28,8 @@ export default async function HousekeepingDomainPage({
|
||||
registry,
|
||||
HOUSEKEEPING_ROUTE_HANDLERS,
|
||||
);
|
||||
const context = await getHousekeepingCapabilityContext();
|
||||
|
||||
if (segments.length === 0) {
|
||||
const context = await getHousekeepingCapabilityContext();
|
||||
const navigation = buildHousekeepingNavigation(
|
||||
runtime,
|
||||
context,
|
||||
@@ -56,8 +44,8 @@ export default async function HousekeepingDomainPage({
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join("/");
|
||||
const canonicalPath = suffix
|
||||
? `${activeDomain.previewHref}/${suffix}`
|
||||
: activeDomain.previewHref;
|
||||
? `${activeDomain.canonicalHref}/${suffix}`
|
||||
: activeDomain.canonicalHref;
|
||||
const match = runtime.match(canonicalPath);
|
||||
|
||||
if (!match || match.domain !== activeDomain.id) notFound();
|
||||
@@ -66,6 +54,8 @@ export default async function HousekeepingDomainPage({
|
||||
const handler = runtime.handlers.get(match.routeId);
|
||||
if (!route || !handler) notFound();
|
||||
|
||||
const context = await getHousekeepingCapabilityContext();
|
||||
|
||||
if (
|
||||
!satisfiesCapability(context, activeDomain.capability) ||
|
||||
!satisfiesCapability(context, route.capability)
|
||||
@@ -79,6 +69,6 @@ export default async function HousekeepingDomainPage({
|
||||
context,
|
||||
match,
|
||||
searchParams: searchParams ? await searchParams : undefined,
|
||||
translate: (key) => translate(namespaceKey(key) as never),
|
||||
translate: (key) => translate(key as never),
|
||||
});
|
||||
}
|
||||
@@ -6,9 +6,9 @@ import { buildHousekeepingNavigation } from "@/features/housekeeping/foundation/
|
||||
import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
|
||||
import { createHousekeepingRouteRuntime } from "@/features/housekeeping/foundation/routing/runtime";
|
||||
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";
|
||||
import { HOUSEKEEPING_ROUTE_HANDLERS } from "@/features/housekeeping/route-handlers";
|
||||
import { HousekeepingShell } from "@/features/housekeeping/shell";
|
||||
|
||||
const MESSAGE_PREFIX = "pages.housekeeping.";
|
||||
|
||||
@@ -20,7 +20,7 @@ function namespaceKey(key: string): string {
|
||||
return key.slice(MESSAGE_PREFIX.length);
|
||||
}
|
||||
|
||||
export default async function AdminNextDomainLayout({
|
||||
export default async function HousekeepingDomainLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
@@ -29,14 +29,14 @@ export default async function AdminNextDomainLayout({
|
||||
}) {
|
||||
const { domain } = await params;
|
||||
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
|
||||
const runtime = createHousekeepingRouteRuntime(
|
||||
registry,
|
||||
HOUSEKEEPING_ROUTE_HANDLERS,
|
||||
);
|
||||
const activeDomain = registry.domains.find((entry) => entry.id === domain);
|
||||
|
||||
if (!activeDomain) notFound();
|
||||
|
||||
const runtime = createHousekeepingRouteRuntime(
|
||||
registry,
|
||||
HOUSEKEEPING_ROUTE_HANDLERS,
|
||||
);
|
||||
const context = await getHousekeepingCapabilityContext();
|
||||
if (!satisfiesCapability(context, activeDomain.capability)) forbidden();
|
||||
|
||||
@@ -55,11 +55,23 @@ export default async function AdminNextDomainLayout({
|
||||
primaryNavigation: translate("navigation.primary"),
|
||||
contextualNavigation: translate("navigation.contextual"),
|
||||
command: translate("preview.commandDisabled"),
|
||||
preview: translate("preview.badge"),
|
||||
backToSite: translate("preview.backToSite"),
|
||||
operatorRank: translate("navigation.operatorRank", {
|
||||
rank: context.actor.rank,
|
||||
}),
|
||||
commandDeck: {
|
||||
placeholder: translate("commandDeck.placeholder"),
|
||||
navigation: translate("commandDeck.navigation"),
|
||||
commands: translate("commandDeck.commands"),
|
||||
entities: translate("commandDeck.entities"),
|
||||
loading: translate("commandDeck.loading"),
|
||||
empty: translate("commandDeck.empty"),
|
||||
partial: translate("commandDeck.partial"),
|
||||
close: translate("commandDeck.close"),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</HousekeepingShell>
|
||||
);
|
||||
}
|
||||
}
|
||||
+73
-10
@@ -1,26 +1,89 @@
|
||||
import { forbidden, redirect } from "next/navigation";
|
||||
import { forbidden, notFound, redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { satisfiesCapability } from "@/features/housekeeping/foundation/capability-context";
|
||||
import { buildHousekeepingNavigation } from "@/features/housekeeping/foundation/navigation";
|
||||
import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
|
||||
import { createHousekeepingRouteRuntime } from "@/features/housekeeping/foundation/routing/runtime";
|
||||
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
|
||||
import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests";
|
||||
import { HOUSEKEEPING_ROUTE_HANDLERS } from "@/features/housekeeping/route-handlers";
|
||||
import { HousekeepingShell } from "@/features/housekeeping/shell";
|
||||
|
||||
export default async function HousekeepingPage() {
|
||||
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 HousekeepingRootPage() {
|
||||
const context = await getHousekeepingCapabilityContext();
|
||||
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
|
||||
const runtime = createHousekeepingRouteRuntime(
|
||||
registry,
|
||||
HOUSEKEEPING_ROUTE_HANDLERS,
|
||||
);
|
||||
const navigation = buildHousekeepingNavigation(
|
||||
runtime,
|
||||
context,
|
||||
(key) => key,
|
||||
const operations = registry.domains.find((domain) => domain.id === "operations");
|
||||
const route = operations?.routes.find(
|
||||
(entry) => entry.id === "operations.workspace",
|
||||
);
|
||||
const firstAccessibleRoute = navigation[0];
|
||||
const match = runtime.match("/ase-next");
|
||||
const handler = runtime.handlers.get("operations.workspace");
|
||||
|
||||
if (!firstAccessibleRoute) forbidden();
|
||||
if (!operations || !route || !match || !handler) notFound();
|
||||
if (
|
||||
!satisfiesCapability(context, operations.capability) ||
|
||||
!satisfiesCapability(context, route.capability)
|
||||
) {
|
||||
const fallback = buildHousekeepingNavigation(
|
||||
runtime,
|
||||
context,
|
||||
(key) => key,
|
||||
)[0];
|
||||
if (!fallback) forbidden();
|
||||
redirect(fallback.href);
|
||||
}
|
||||
|
||||
redirect(firstAccessibleRoute.href);
|
||||
}
|
||||
const translate = await getTranslations("pages.housekeeping");
|
||||
const navigation = buildHousekeepingNavigation(runtime, context, (key) =>
|
||||
translate(namespaceKey(key) as never),
|
||||
);
|
||||
const workspace = await handler.render({
|
||||
context,
|
||||
match,
|
||||
translate: (key) => translate(key as never),
|
||||
});
|
||||
|
||||
return (
|
||||
<HousekeepingShell
|
||||
actor={context.actor}
|
||||
activeDomainId="operations"
|
||||
domains={navigation}
|
||||
labels={{
|
||||
skipToContent: translate("navigation.skipToContent"),
|
||||
primaryNavigation: translate("navigation.primary"),
|
||||
contextualNavigation: translate("navigation.contextual"),
|
||||
command: translate("preview.commandDisabled"),
|
||||
backToSite: translate("preview.backToSite"),
|
||||
operatorRank: translate("navigation.operatorRank", {
|
||||
rank: context.actor.rank,
|
||||
}),
|
||||
commandDeck: {
|
||||
placeholder: translate("commandDeck.placeholder"),
|
||||
navigation: translate("commandDeck.navigation"),
|
||||
commands: translate("commandDeck.commands"),
|
||||
entities: translate("commandDeck.entities"),
|
||||
loading: translate("commandDeck.loading"),
|
||||
empty: translate("commandDeck.empty"),
|
||||
partial: translate("commandDeck.partial"),
|
||||
close: translate("commandDeck.close"),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{workspace}
|
||||
</HousekeepingShell>
|
||||
);
|
||||
}
|
||||
@@ -1734,6 +1734,42 @@ details[open] > summary .details-open\:rotate-180 {
|
||||
}
|
||||
}
|
||||
|
||||
/* Housekeeping command deck: keyboard focus, narrow screens, and motion safety. */
|
||||
[data-housekeeping-root] :is(a, button, input, select, textarea):focus-visible {
|
||||
outline: 2px solid var(--admin-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
#housekeeping-content {
|
||||
max-width: 100%;
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
#housekeeping-content table {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-inline: contain;
|
||||
}
|
||||
|
||||
#housekeeping-content :is(pre, code, img, svg, canvas) {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-housekeeping-root],
|
||||
[data-housekeeping-root] * {
|
||||
/* biome-ignore lint/complexity/noImportantStyles: accessibility preference must override component styles */
|
||||
scroll-behavior: auto !important;
|
||||
/* biome-ignore lint/complexity/noImportantStyles: accessibility preference must override component styles */
|
||||
animation-duration: 0.01ms !important;
|
||||
/* biome-ignore lint/complexity/noImportantStyles: accessibility preference must override component styles */
|
||||
animation-iteration-count: 1 !important;
|
||||
/* biome-ignore lint/complexity/noImportantStyles: accessibility preference must override component styles */
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Premium effects ── */
|
||||
@keyframes float {
|
||||
0%,
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ export default function robots(): MetadataRoute.Robots {
|
||||
rules: {
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
disallow: ["/admin/", "/api/", "/settings/", "/me/"],
|
||||
disallow: ["/ase/", "/api/", "/settings/", "/me/"],
|
||||
},
|
||||
sitemap: `${appUrl}/sitemap.xml`,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { type BcPageData, BcPageDetail } from "./bc-manager";
|
||||
|
||||
vi.mock("@/actions/catalog-bc", () => ({
|
||||
createBcItem: vi.fn(),
|
||||
deleteBcItem: vi.fn(),
|
||||
updateBcItem: vi.fn(),
|
||||
updateBcPage: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/components/admin/confirm-dialog", () => ({
|
||||
useConfirmDialog: () => ({ confirm: vi.fn(), dialog: null }),
|
||||
}));
|
||||
vi.mock("@/hooks/use-server-action", () => ({
|
||||
useServerAction: () => ({ isPending: false, run: vi.fn() }),
|
||||
}));
|
||||
|
||||
const page: BcPageData = {
|
||||
id: 7,
|
||||
parentId: -1,
|
||||
caption: "Builder Club",
|
||||
pageLayout: "default_3x3",
|
||||
iconColor: 0,
|
||||
iconImage: 1,
|
||||
orderNum: 1,
|
||||
visible: "1",
|
||||
enabled: "1",
|
||||
pageHeadline: "",
|
||||
pageTeaser: "",
|
||||
pageSpecial: "",
|
||||
pageText1: "",
|
||||
pageText2: "",
|
||||
pageTextDetails: "",
|
||||
pageTextTeaser: "",
|
||||
};
|
||||
|
||||
describe("BcPageDetail", () => {
|
||||
it("uses the canonical return route when embedded in Housekeeping", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<BcPageDetail
|
||||
page={page}
|
||||
items={[]}
|
||||
canEdit={false}
|
||||
returnHref="/ase/economy/catalog"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('href="/ase/economy/catalog"');
|
||||
expect(markup).not.toContain('href="/admin/catalog?catalog=bc"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,614 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowLeft,
|
||||
HardHat,
|
||||
Loader2,
|
||||
Package,
|
||||
Pencil,
|
||||
Plus,
|
||||
Save,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
createBcItem,
|
||||
deleteBcItem,
|
||||
updateBcItem,
|
||||
updateBcPage,
|
||||
} from "@/actions/catalog-bc";
|
||||
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||
import Link from "@/components/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BcPageData {
|
||||
id: number;
|
||||
parentId: number;
|
||||
caption: string;
|
||||
pageLayout: string;
|
||||
iconColor: number;
|
||||
iconImage: number;
|
||||
orderNum: number;
|
||||
visible: string;
|
||||
enabled: string;
|
||||
pageHeadline: string;
|
||||
pageTeaser: string;
|
||||
pageSpecial: string;
|
||||
pageText1: string;
|
||||
pageText2: string;
|
||||
pageTextDetails: string;
|
||||
pageTextTeaser: string;
|
||||
}
|
||||
|
||||
export interface BcItemData {
|
||||
id: number;
|
||||
pageId: number;
|
||||
itemIds: string;
|
||||
catalogName: string;
|
||||
orderNumber: number;
|
||||
extradata: string;
|
||||
}
|
||||
|
||||
interface BcPageDetailProps {
|
||||
page: BcPageData;
|
||||
items: BcItemData[];
|
||||
canEdit: boolean;
|
||||
returnHref?: string;
|
||||
}
|
||||
|
||||
// ── Main Component ───────────────────────────────────────────────────
|
||||
|
||||
export function BcPageDetail({
|
||||
page,
|
||||
items,
|
||||
canEdit,
|
||||
returnHref = "/ase/economy/catalog",
|
||||
}: BcPageDetailProps) {
|
||||
const { isPending, run } = useServerAction();
|
||||
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const [editingItem, setEditingItem] = useState<BcItemData | null>(null);
|
||||
const [addingItem, setAddingItem] = useState(false);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
caption: page.caption,
|
||||
orderNum: page.orderNum,
|
||||
enabled: page.enabled as "0" | "1",
|
||||
visible: page.visible as "0" | "1",
|
||||
pageHeadline: page.pageHeadline,
|
||||
pageTeaser: page.pageTeaser,
|
||||
pageSpecial: page.pageSpecial,
|
||||
pageText1: page.pageText1,
|
||||
pageText2: page.pageText2,
|
||||
pageTextDetails: page.pageTextDetails,
|
||||
pageTextTeaser: page.pageTextTeaser,
|
||||
});
|
||||
|
||||
// Ctrl+S / Cmd+S to save
|
||||
useEffect(() => {
|
||||
if (!canEdit) return;
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
formRef.current?.requestSubmit();
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [canEdit]);
|
||||
|
||||
function update<K extends keyof typeof form>(
|
||||
key: K,
|
||||
value: (typeof form)[K],
|
||||
) {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
run(() => updateBcPage({ id: page.id, ...form }), {
|
||||
successMessage: "BC page updated and catalog synced.",
|
||||
errorMessage: "Failed to update.",
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{confirmDialog}
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href={returnHref}
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
BC Catalog
|
||||
</Link>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<HardHat className="h-5 w-5 text-[var(--admin-warning)]" />
|
||||
<h1 className="text-2xl font-bold tracking-tight">{page.caption}</h1>
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
#{page.id}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="font-mono text-xs">
|
||||
{page.pageLayout}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form ref={formRef} onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
{/* ── Left column: form fields ─────────────── */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Page Settings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="caption">Caption</Label>
|
||||
<Input
|
||||
id="caption"
|
||||
value={form.caption}
|
||||
onChange={(e) => update("caption", e.target.value)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="orderNum">Order</Label>
|
||||
<Input
|
||||
id="orderNum"
|
||||
type="number"
|
||||
value={form.orderNum}
|
||||
onChange={(e) =>
|
||||
update("orderNum", parseInt(e.target.value, 10) || 0)
|
||||
}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Headline</Label>
|
||||
<Input
|
||||
value={form.pageHeadline}
|
||||
onChange={(e) => update("pageHeadline", e.target.value)}
|
||||
disabled={!canEdit}
|
||||
placeholder="Headline image path"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Teaser</Label>
|
||||
<Input
|
||||
value={form.pageTeaser}
|
||||
onChange={(e) => update("pageTeaser", e.target.value)}
|
||||
disabled={!canEdit}
|
||||
placeholder="Teaser image path"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Text 1</Label>
|
||||
<Textarea
|
||||
value={form.pageText1}
|
||||
onChange={(e) => update("pageText1", e.target.value)}
|
||||
disabled={!canEdit}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Text 2</Label>
|
||||
<Textarea
|
||||
value={form.pageText2}
|
||||
onChange={(e) => update("pageText2", e.target.value)}
|
||||
disabled={!canEdit}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Details</Label>
|
||||
<Textarea
|
||||
value={form.pageTextDetails}
|
||||
onChange={(e) =>
|
||||
update("pageTextDetails", e.target.value)
|
||||
}
|
||||
disabled={!canEdit}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Text Teaser</Label>
|
||||
<Textarea
|
||||
value={form.pageTextTeaser}
|
||||
onChange={(e) => update("pageTextTeaser", e.target.value)}
|
||||
disabled={!canEdit}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Special</Label>
|
||||
<Textarea
|
||||
value={form.pageSpecial}
|
||||
onChange={(e) => update("pageSpecial", e.target.value)}
|
||||
disabled={!canEdit}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Items */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Package className="h-4 w-4" />
|
||||
Items ({items.length})
|
||||
</CardTitle>
|
||||
{canEdit && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setAddingItem(true)}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
Add Item
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No items in this page.
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="text-xs">ID</TableHead>
|
||||
<TableHead className="text-xs">Name</TableHead>
|
||||
<TableHead className="text-xs">Item IDs</TableHead>
|
||||
<TableHead className="text-xs">Order</TableHead>
|
||||
<TableHead className="text-xs">Extra Data</TableHead>
|
||||
{canEdit && (
|
||||
<TableHead className="text-xs w-20">
|
||||
Actions
|
||||
</TableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-xs font-mono">
|
||||
{item.id}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-medium">
|
||||
{item.catalogName}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-mono text-muted-foreground">
|
||||
{item.itemIds}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{item.orderNumber}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground max-w-32 truncate">
|
||||
{item.extradata || "\u2014"}
|
||||
</TableCell>
|
||||
{canEdit && (
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => setEditingItem(item)}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-[var(--admin-error)] hover:text-[var(--admin-error)]"
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: "Delete BC item",
|
||||
description: `Delete item "${item.catalogName}"?`,
|
||||
confirmLabel: "Delete",
|
||||
});
|
||||
if (!ok) return;
|
||||
run(() => deleteBcItem({ id: item.id }), {
|
||||
successMessage: "Item deleted.",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* ── Right column: status ─────────────────── */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card className="lg:sticky lg:top-4">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<ToggleRow
|
||||
id="tg-enabled"
|
||||
label="Enabled"
|
||||
description="Active in the BC catalog"
|
||||
checked={form.enabled === "1"}
|
||||
onChange={(v) => update("enabled", v ? "1" : "0")}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<ToggleRow
|
||||
id="tg-visible"
|
||||
label="Visible"
|
||||
description="Shown in BC navigation"
|
||||
checked={form.visible === "1"}
|
||||
onChange={(v) => update("visible", v ? "1" : "0")}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-4 border-t pt-3 text-xs text-muted-foreground">
|
||||
<span>
|
||||
Layout: <span className="font-mono">{page.pageLayout}</span>
|
||||
</span>
|
||||
<span>
|
||||
Parent:{" "}
|
||||
<span className="font-mono">
|
||||
{page.parentId <= 0 ? "Root" : `#${page.parentId}`}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{canEdit && (
|
||||
<div className="space-y-2 border-t pt-3">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="w-full"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save & Sync
|
||||
</Button>
|
||||
<p className="text-center text-[10px] text-muted-foreground">
|
||||
Press <kbd className="rounded border px-1">Ctrl+S</kbd> to
|
||||
save
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Edit Item Dialog */}
|
||||
{editingItem && (
|
||||
<ItemDialog
|
||||
item={editingItem}
|
||||
mode="edit"
|
||||
isPending={isPending}
|
||||
onClose={() => setEditingItem(null)}
|
||||
onSubmit={(data) => {
|
||||
run(() => updateBcItem({ id: editingItem.id, ...data }), {
|
||||
successMessage: "Item updated.",
|
||||
onSuccess: () => setEditingItem(null),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Add Item Dialog */}
|
||||
{addingItem && (
|
||||
<ItemDialog
|
||||
item={{
|
||||
id: 0,
|
||||
pageId: page.id,
|
||||
itemIds: "",
|
||||
catalogName: "",
|
||||
orderNumber: 1,
|
||||
extradata: "",
|
||||
}}
|
||||
mode="add"
|
||||
isPending={isPending}
|
||||
onClose={() => setAddingItem(false)}
|
||||
onSubmit={(data) => {
|
||||
run(() => createBcItem({ pageId: page.id, ...data }), {
|
||||
successMessage: "Item created.",
|
||||
onSuccess: () => setAddingItem(false),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Toggle row ───────────────────────────────────────────────────────
|
||||
|
||||
function ToggleRow({
|
||||
id,
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Label htmlFor={id} className="text-sm font-medium cursor-pointer">
|
||||
{label}
|
||||
</Label>
|
||||
<p className="text-[11px] text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<Switch
|
||||
id={id}
|
||||
checked={checked}
|
||||
onCheckedChange={onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Item Dialog ──────────────────────────────────────────────────────
|
||||
|
||||
function ItemDialog({
|
||||
item,
|
||||
mode,
|
||||
isPending,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
item: BcItemData;
|
||||
mode: "add" | "edit";
|
||||
isPending: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: {
|
||||
itemIds: string;
|
||||
catalogName: string;
|
||||
orderNumber: number;
|
||||
extradata: string;
|
||||
}) => void;
|
||||
}) {
|
||||
const [form, setForm] = useState({
|
||||
itemIds: item.itemIds,
|
||||
catalogName: item.catalogName,
|
||||
orderNumber: item.orderNumber,
|
||||
extradata: item.extradata,
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{mode === "add" ? "Add BC Item" : `Edit Item #${item.id}`}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Catalog Name</Label>
|
||||
<Input
|
||||
value={form.catalogName}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, catalogName: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Item IDs (semicolon-separated)</Label>
|
||||
<Input
|
||||
value={form.itemIds}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, itemIds: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Order</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.orderNumber}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
orderNumber: parseInt(e.target.value, 10) || 0,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Extra Data</Label>
|
||||
<Input
|
||||
value={form.extradata}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, extradata: e.target.value }))
|
||||
}
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onSubmit(form)}
|
||||
disabled={isPending || !form.catalogName || !form.itemIds}
|
||||
>
|
||||
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{mode === "add" ? "Add Item" : "Save"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { Package } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { catalogueIconUrl } from "@/lib/catalog-assets";
|
||||
|
||||
export function CatalogIcon({
|
||||
iconImage,
|
||||
size = 20,
|
||||
}: {
|
||||
iconImage: number;
|
||||
size?: number;
|
||||
}) {
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
if (error || iconImage <= 0) {
|
||||
return <Package className="h-4 w-4 shrink-0 text-muted-foreground" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={catalogueIconUrl(iconImage)}
|
||||
alt=""
|
||||
width={size}
|
||||
height={size}
|
||||
className="shrink-0 object-contain"
|
||||
style={{ imageRendering: "pixelated", minWidth: size, minHeight: size }}
|
||||
onError={() => setError(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const LAYOUT_COLORS: Record<string, string> = {
|
||||
default_3x3:
|
||||
"bg-[var(--admin-info-subtle)] text-[var(--admin-info)] dark:text-[var(--admin-info)]",
|
||||
frontpage:
|
||||
"bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] dark:text-[var(--admin-warning)]",
|
||||
spaces_new:
|
||||
"bg-[var(--admin-success-subtle)] text-[var(--admin-success)] dark:text-[var(--admin-success)]",
|
||||
pets: "bg-[var(--admin-error)]/15 text-[var(--admin-error)]",
|
||||
pets2: "bg-[var(--admin-error)]/15 text-[var(--admin-error)]",
|
||||
pets3: "bg-[var(--admin-error)]/15 text-[var(--admin-error)]",
|
||||
marketplace: "bg-[var(--admin-accent)]/15 text-[var(--admin-accent-text)]",
|
||||
recycler:
|
||||
"bg-[var(--admin-success-subtle)] text-[var(--admin-success)] dark:text-[var(--admin-success)]",
|
||||
club_buy:
|
||||
"bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] dark:text-[var(--admin-warning)]",
|
||||
single_bundle:
|
||||
"bg-[var(--admin-info-subtle)] text-[var(--admin-info)] dark:text-[var(--admin-info)]",
|
||||
room_bundle:
|
||||
"bg-[var(--admin-info-subtle)] text-[var(--admin-info)] dark:text-[var(--admin-info)]",
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { Languages, Package, Settings } from "lucide-react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { type CatalogItemData, CatalogItemsTable } from "./catalog-items-table";
|
||||
import { CatalogPageForm } from "./catalog-page-form";
|
||||
import { CatalogTranslateTab } from "./catalog-translate-tab";
|
||||
|
||||
interface CatalogPageData {
|
||||
id: number;
|
||||
caption: string;
|
||||
parentId: number;
|
||||
pageLayout: string;
|
||||
enabled: string;
|
||||
visible: string;
|
||||
minRank: number;
|
||||
clubOnly: string;
|
||||
orderNum: number;
|
||||
iconImage: number;
|
||||
iconColor: number;
|
||||
pageHeadline: string;
|
||||
pageTeaser: string;
|
||||
pageSpecial: string | null;
|
||||
pageText1: string | null;
|
||||
pageText2: string | null;
|
||||
pageTextDetails: string | null;
|
||||
pageTextTeaser: string | null;
|
||||
}
|
||||
|
||||
interface BaseItemData {
|
||||
id: number;
|
||||
publicName: string;
|
||||
itemName: string;
|
||||
spriteId: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface CatalogDetailTabsProps {
|
||||
catalogPage: CatalogPageData;
|
||||
items: CatalogItemData[];
|
||||
baseItems: BaseItemData[];
|
||||
catalogNameMap: Record<number, string>;
|
||||
childPages: { id: number; caption: string; enabled: string }[];
|
||||
pageId: number;
|
||||
canEdit: boolean;
|
||||
furniDataIdList: number[];
|
||||
furniDescriptionMap: Record<number, string>;
|
||||
furniRevisionMap: Record<number, { classname: string; revision: number }>;
|
||||
allPages: { id: number; caption: string }[];
|
||||
interactionTypes: string[];
|
||||
gamedataHotel: string;
|
||||
}
|
||||
|
||||
export function CatalogDetailTabs({
|
||||
catalogPage,
|
||||
items,
|
||||
baseItems,
|
||||
catalogNameMap,
|
||||
childPages,
|
||||
pageId,
|
||||
canEdit,
|
||||
furniDataIdList,
|
||||
furniDescriptionMap,
|
||||
furniRevisionMap,
|
||||
allPages,
|
||||
interactionTypes,
|
||||
gamedataHotel,
|
||||
}: CatalogDetailTabsProps) {
|
||||
return (
|
||||
<Tabs defaultValue="settings">
|
||||
<TabsList className="overflow-x-auto flex-nowrap w-full">
|
||||
<TabsTrigger value="settings" className="gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
Settings
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="items" className="gap-2">
|
||||
<Package className="h-4 w-4" />
|
||||
Items ({items.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="translate" className="gap-2">
|
||||
<Languages className="h-4 w-4" />
|
||||
Translate ({baseItems.length}){(() => {
|
||||
const furniSet = new Set(furniDataIdList);
|
||||
const missing = baseItems.filter((b) => !furniSet.has(b.id)).length;
|
||||
return missing > 0 ? (
|
||||
<span className="ml-1 rounded-full bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] text-[10px] px-1.5 py-0.5 leading-none font-medium">
|
||||
{missing}
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="settings" className="mt-4">
|
||||
<CatalogPageForm
|
||||
catalogPage={catalogPage}
|
||||
childPages={childPages}
|
||||
canEdit={canEdit}
|
||||
allPages={allPages}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="items" className="mt-4">
|
||||
<CatalogItemsTable
|
||||
items={items}
|
||||
pageId={pageId}
|
||||
pageLayout={catalogPage.pageLayout}
|
||||
canEdit={canEdit}
|
||||
interactionTypes={interactionTypes}
|
||||
furniRevisionMap={furniRevisionMap}
|
||||
allPages={allPages}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="translate" className="mt-4">
|
||||
<CatalogTranslateTab
|
||||
baseItems={baseItems}
|
||||
catalogNameMap={catalogNameMap}
|
||||
canEdit={canEdit}
|
||||
furniDataIdList={furniDataIdList}
|
||||
furniDescriptionMap={furniDescriptionMap}
|
||||
furniRevisionMap={furniRevisionMap}
|
||||
gamedataHotel={gamedataHotel}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Re-export barrel for backward compatibility.
|
||||
* Implementation moved to ./catalog-items-table/ directory.
|
||||
*/
|
||||
export { CatalogItemsTable } from "./catalog-items-table/catalog-items-table";
|
||||
export { FurniIcon } from "./catalog-items-table/furni-icon";
|
||||
export type {
|
||||
BaseItemData,
|
||||
CatalogItemData,
|
||||
} from "./catalog-items-table/types";
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,281 @@
|
||||
"use client";
|
||||
|
||||
import { Check, ChevronsUpDown, Star } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ── Toggle field ──────────────────────────────────────────────
|
||||
|
||||
export function ToggleField({
|
||||
label,
|
||||
checked,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
/** Boolean checked state */
|
||||
checked?: boolean;
|
||||
/** Number or string (0/1 or '0'/'1') — converted to boolean automatically */
|
||||
value?: number | string;
|
||||
onChange: (v: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const isChecked = checked ?? (value === 1 || value === "1");
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label className="text-xs">{label}</Label>
|
||||
<Switch
|
||||
checked={isChecked}
|
||||
onCheckedChange={onChange}
|
||||
disabled={disabled}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Interaction Type Combobox ──────────────────────────────────
|
||||
|
||||
export function InteractionTypeCombobox({
|
||||
value,
|
||||
onChange,
|
||||
types,
|
||||
disabled,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
types: string[];
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!inputValue.trim()) return types.slice(0, 50);
|
||||
const q = inputValue.toLowerCase();
|
||||
return types.filter((t) => t.toLowerCase().includes(q)).slice(0, 50);
|
||||
}, [types, inputValue]);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className="w-full justify-between font-normal h-8 text-sm"
|
||||
>
|
||||
<span className="truncate">{value || "Select type..."}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[--radix-popover-trigger-width] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder="Search or type custom..."
|
||||
value={inputValue}
|
||||
onValueChange={setInputValue}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{inputValue.trim() ? (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-2 py-1.5 text-left text-sm hover:bg-accent rounded"
|
||||
onClick={() => {
|
||||
onChange(inputValue.trim());
|
||||
setOpen(false);
|
||||
setInputValue("");
|
||||
}}
|
||||
>
|
||||
Use "{inputValue.trim()}"
|
||||
</button>
|
||||
) : (
|
||||
"No types found."
|
||||
)}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{filtered.map((t) => (
|
||||
<CommandItem
|
||||
key={t}
|
||||
value={t}
|
||||
onSelect={() => {
|
||||
onChange(t);
|
||||
setOpen(false);
|
||||
setInputValue("");
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === t ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">{t}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Move Page Combobox with smart suggestions ─────────────────
|
||||
|
||||
export function MovePageCombobox({
|
||||
value,
|
||||
onChange,
|
||||
pages,
|
||||
currentPageId,
|
||||
suggestedPageIds,
|
||||
}: {
|
||||
value: number | null;
|
||||
onChange: (id: number | null) => void;
|
||||
pages: { id: number; caption: string }[];
|
||||
currentPageId: number;
|
||||
/** Page IDs to show first as suggestions (from move-suggestions.ts) */
|
||||
suggestedPageIds?: number[];
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const options = useMemo(
|
||||
() => pages.filter((p) => p.id !== currentPageId),
|
||||
[pages, currentPageId],
|
||||
);
|
||||
|
||||
const selectedPage = value ? pages.find((p) => p.id === value) : null;
|
||||
|
||||
// Build suggested pages list
|
||||
const suggested = useMemo(() => {
|
||||
if (!suggestedPageIds?.length) return [];
|
||||
const pageMap = new Map(options.map((p) => [p.id, p]));
|
||||
return suggestedPageIds.map((id) => pageMap.get(id)).filter(Boolean) as {
|
||||
id: number;
|
||||
caption: string;
|
||||
}[];
|
||||
}, [suggestedPageIds, options]);
|
||||
|
||||
// Non-suggested pages (exclude those already in suggestions)
|
||||
const suggestedSet = useMemo(
|
||||
() => new Set(suggested.map((s) => s.id)),
|
||||
[suggested],
|
||||
);
|
||||
const remaining = useMemo(
|
||||
() => options.filter((p) => !suggestedSet.has(p.id)),
|
||||
[options, suggestedSet],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
{selectedPage ? (
|
||||
<span className="truncate">
|
||||
<span className="text-muted-foreground font-mono text-xs mr-1.5">
|
||||
#{selectedPage.id}
|
||||
</span>
|
||||
{selectedPage.caption}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Select target page...</span>
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[--radix-popover-trigger-width] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search pages..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No page found.</CommandEmpty>
|
||||
|
||||
{/* Suggested pages */}
|
||||
{suggested.length > 0 && (
|
||||
<CommandGroup heading="Suggested">
|
||||
{suggested.map((page) => (
|
||||
<CommandItem
|
||||
key={`s-${page.id}`}
|
||||
value={`${page.id} ${page.caption}`}
|
||||
onSelect={() => {
|
||||
onChange(page.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === page.id ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<Star className="mr-1.5 h-3 w-3 text-[var(--admin-warning)]" />
|
||||
<span className="text-muted-foreground font-mono text-xs mr-1.5">
|
||||
#{page.id}
|
||||
</span>
|
||||
<span className="truncate">{page.caption}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* All pages */}
|
||||
<CommandGroup
|
||||
heading={suggested.length > 0 ? "All pages" : undefined}
|
||||
>
|
||||
{remaining.map((page) => (
|
||||
<CommandItem
|
||||
key={page.id}
|
||||
value={`${page.id} ${page.caption}`}
|
||||
onSelect={() => {
|
||||
onChange(page.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === page.id ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span className="text-muted-foreground font-mono text-xs mr-1.5">
|
||||
#{page.id}
|
||||
</span>
|
||||
<span className="truncate">{page.caption}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { Image as ImageIcon } from "lucide-react";
|
||||
import { getHabboCdnIconUrl, getLocalIconUrl } from "@/lib/furni/classname";
|
||||
|
||||
export function FurniIcon({
|
||||
classname,
|
||||
revision,
|
||||
}: {
|
||||
classname: string;
|
||||
revision: number;
|
||||
}) {
|
||||
if (!classname) {
|
||||
return (
|
||||
<div className="w-10 h-10 rounded bg-muted flex items-center justify-center">
|
||||
<ImageIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const localUrl = getLocalIconUrl(classname);
|
||||
|
||||
function handleError(e: React.SyntheticEvent<HTMLImageElement>) {
|
||||
const img = e.target as HTMLImageElement;
|
||||
const stage = img.dataset.fallback;
|
||||
if (!stage && revision > 0) {
|
||||
img.dataset.fallback = "cdn";
|
||||
img.src = getHabboCdnIconUrl(classname, revision);
|
||||
} else {
|
||||
img.dataset.fallback = "none";
|
||||
img.style.opacity = "0";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-10 h-10 rounded bg-muted/50 flex items-center justify-center overflow-hidden">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={localUrl}
|
||||
alt={classname}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
onError={handleError}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, Music } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
getCachedSongPickerOptions,
|
||||
invalidateSongPickerCache,
|
||||
loadSongPickerOptions,
|
||||
type SoundtrackOption,
|
||||
} from "@/lib/client-cache/song-picker-cache";
|
||||
|
||||
// Re-export so existing `import { invalidateSongPickerCache } from '.../song-picker'`
|
||||
// callsites keep working. New code should import from the lib module directly.
|
||||
export { invalidateSongPickerCache };
|
||||
|
||||
interface Props {
|
||||
value: number;
|
||||
onChange: (id: number) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function formatLength(seconds: number): string {
|
||||
if (!seconds || seconds <= 0) return "";
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function SongPicker({ value, onChange, disabled }: Props) {
|
||||
const initial = getCachedSongPickerOptions();
|
||||
const [options, setOptions] = useState<SoundtrackOption[]>(initial ?? []);
|
||||
const [loading, setLoading] = useState(!initial);
|
||||
|
||||
useEffect(() => {
|
||||
if (getCachedSongPickerOptions()) return;
|
||||
let cancelled = false;
|
||||
loadSongPickerOptions()
|
||||
.then((items) => {
|
||||
if (!cancelled) setOptions(items);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setOptions([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={String(value)}
|
||||
onValueChange={(v) => onChange(parseInt(v, 10) || 0)}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
<SelectTrigger>
|
||||
{loading ? (
|
||||
<span className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> Loading…
|
||||
</span>
|
||||
) : (
|
||||
<SelectValue placeholder="No song" />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">
|
||||
<span className="text-muted-foreground">— No song —</span>
|
||||
</SelectItem>
|
||||
{options.map((opt) => (
|
||||
<SelectItem key={opt.id} value={String(opt.id)}>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Music className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="font-medium">{opt.name}</span>
|
||||
{opt.author && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
— {opt.author}
|
||||
</span>
|
||||
)}
|
||||
{opt.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{formatLength(opt.length)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
export interface BaseItemData {
|
||||
id: number;
|
||||
spriteId: number;
|
||||
publicName: string;
|
||||
itemName: string;
|
||||
type: string;
|
||||
width: number;
|
||||
length: number;
|
||||
stackHeight: number;
|
||||
allowStack: number;
|
||||
allowSit: number;
|
||||
allowLay: number;
|
||||
allowWalk: number;
|
||||
allowGift: number;
|
||||
allowTrade: number;
|
||||
allowRecycle: number;
|
||||
allowMarketplaceSell: number;
|
||||
allowInventoryStack: number;
|
||||
interactionType: string;
|
||||
interactionModesCount: number;
|
||||
vendingIds: string;
|
||||
customparams: string;
|
||||
effectIdMale: number;
|
||||
effectIdFemale: number;
|
||||
clothingOnWalk: string;
|
||||
}
|
||||
|
||||
export interface CatalogItemData {
|
||||
id: number;
|
||||
catalogName: string;
|
||||
itemIds: string;
|
||||
costCredits: number;
|
||||
costPoints: number;
|
||||
pointsType: number;
|
||||
amount: number;
|
||||
limitedSells: number;
|
||||
limitedStack: number;
|
||||
orderNumber: number;
|
||||
offerId: number;
|
||||
songId: number;
|
||||
haveOffer: string;
|
||||
clubOnly: string;
|
||||
extradata: string;
|
||||
baseName: string;
|
||||
baseItemName: string;
|
||||
spriteId: number;
|
||||
baseItem: BaseItemData | null;
|
||||
}
|
||||
|
||||
export interface InlineEdit {
|
||||
costCredits: number;
|
||||
costPoints: number;
|
||||
pointsType: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export const POINTS_TYPES: Record<number, { label: string; color: string }> = {
|
||||
0: { label: "Duckets", color: "text-[var(--admin-info)]" },
|
||||
5: { label: "Diamonds", color: "text-[var(--admin-accent-text)]" },
|
||||
101: { label: "Snowflakes", color: "text-[var(--admin-info)]" },
|
||||
103: { label: "Hearts", color: "text-[var(--admin-error)]" },
|
||||
104: { label: "Stars", color: "text-[var(--admin-warning)]" },
|
||||
105: { label: "Shells", color: "text-[var(--admin-warning)]" },
|
||||
};
|
||||
|
||||
export const DEFAULT_NEW_ITEM = {
|
||||
catalogName: "",
|
||||
itemIds: "0",
|
||||
costCredits: 0,
|
||||
costPoints: 0,
|
||||
pointsType: 0,
|
||||
amount: 1,
|
||||
limitedSells: 0,
|
||||
limitedStack: 0,
|
||||
orderNumber: 1,
|
||||
offerId: -1,
|
||||
songId: 0,
|
||||
haveOffer: "1",
|
||||
clubOnly: "0",
|
||||
extradata: "",
|
||||
};
|
||||
|
||||
export function getPointsLabel(type: number) {
|
||||
return (
|
||||
POINTS_TYPES[type] || { label: `Type ${type}`, color: "theme-text-muted" }
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,741 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
FileText,
|
||||
FolderTree,
|
||||
Loader2,
|
||||
Package,
|
||||
Palette,
|
||||
Save,
|
||||
Settings,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { deleteCatalogPage, updateCatalogPage } from "@/actions/catalog";
|
||||
import { CatalogImagePicker } from "@/components/admin/catalog/catalog-image-picker";
|
||||
import {
|
||||
CatalogIcon,
|
||||
LAYOUT_COLORS,
|
||||
} from "@/components/admin/catalog/catalog-visuals";
|
||||
import { IconPicker } from "@/components/admin/catalog/icon-picker";
|
||||
import { ImagePreview } from "@/components/admin/catalog/image-preview";
|
||||
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||
import Link from "@/components/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import {
|
||||
CATALOG_LAYOUTS,
|
||||
type CatalogLayout,
|
||||
LAYOUT_DESCRIPTIONS,
|
||||
} from "@/lib/catalog-layouts";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CatalogPageData {
|
||||
id: number;
|
||||
caption: string;
|
||||
parentId: number;
|
||||
pageLayout: string;
|
||||
enabled: string;
|
||||
visible: string;
|
||||
minRank: number;
|
||||
clubOnly: string;
|
||||
orderNum: number;
|
||||
iconImage: number;
|
||||
iconColor: number;
|
||||
pageHeadline: string;
|
||||
pageTeaser: string;
|
||||
pageSpecial: string | null;
|
||||
pageText1: string | null;
|
||||
pageText2: string | null;
|
||||
pageTextDetails: string | null;
|
||||
pageTextTeaser: string | null;
|
||||
}
|
||||
|
||||
interface CatalogPageFormProps {
|
||||
catalogPage: CatalogPageData;
|
||||
childPages: { id: number; caption: string; enabled: string }[];
|
||||
canEdit: boolean;
|
||||
allPages: { id: number; caption: string }[];
|
||||
}
|
||||
|
||||
export function CatalogPageForm({
|
||||
catalogPage,
|
||||
childPages,
|
||||
canEdit,
|
||||
allPages,
|
||||
}: CatalogPageFormProps) {
|
||||
const { isPending, run } = useServerAction();
|
||||
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
caption: catalogPage.caption,
|
||||
parentId: catalogPage.parentId,
|
||||
pageLayout: catalogPage.pageLayout,
|
||||
enabled: catalogPage.enabled as "0" | "1",
|
||||
visible: catalogPage.visible as "0" | "1",
|
||||
minRank: catalogPage.minRank,
|
||||
clubOnly: catalogPage.clubOnly as "0" | "1",
|
||||
orderNum: catalogPage.orderNum,
|
||||
iconImage: catalogPage.iconImage,
|
||||
iconColor: catalogPage.iconColor,
|
||||
pageHeadline: catalogPage.pageHeadline,
|
||||
pageTeaser: catalogPage.pageTeaser,
|
||||
pageSpecial: catalogPage.pageSpecial || "",
|
||||
pageText1: catalogPage.pageText1 || "",
|
||||
pageText2: catalogPage.pageText2 || "",
|
||||
pageTextDetails: catalogPage.pageTextDetails || "",
|
||||
pageTextTeaser: catalogPage.pageTextTeaser || "",
|
||||
});
|
||||
|
||||
// Ctrl+S / Cmd+S to save
|
||||
useEffect(() => {
|
||||
if (!canEdit) return;
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
formRef.current?.requestSubmit();
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [canEdit]);
|
||||
|
||||
function update<K extends keyof typeof form>(
|
||||
key: K,
|
||||
value: (typeof form)[K],
|
||||
) {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
run(() => updateCatalogPage({ id: catalogPage.id, ...form }), {
|
||||
successMessage: "Page updated and catalog synced.",
|
||||
errorMessage: "Failed to update.",
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
const childCount = childPages.length;
|
||||
const description =
|
||||
childCount > 0
|
||||
? `Delete this catalog page? Its ${childCount} child page(s) will be moved to the parent. All items on this page will be deleted.`
|
||||
: "Delete this catalog page and all its items?";
|
||||
const ok = await confirm({
|
||||
title: "Delete catalog page",
|
||||
description,
|
||||
confirmLabel: "Delete",
|
||||
});
|
||||
if (!ok) return;
|
||||
run(() => deleteCatalogPage({ id: catalogPage.id }), {
|
||||
successMessage: "Page deleted.",
|
||||
redirectTo: "/ase/economy/catalog",
|
||||
});
|
||||
}
|
||||
|
||||
const layoutColorClass =
|
||||
LAYOUT_COLORS[form.pageLayout] || "bg-muted text-muted-foreground";
|
||||
|
||||
return (
|
||||
<>
|
||||
{confirmDialog}
|
||||
<form ref={formRef} onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
{/* ── Left column (2/3): tabs ─────────────────────────── */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<Tabs defaultValue="general">
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="general" className="gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
General
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="texts" className="gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
Texts
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="appearance" className="gap-2">
|
||||
<Palette className="h-4 w-4" />
|
||||
Appearance
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ── General ──────────────────────────────────── */}
|
||||
<TabsContent value="general" className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="caption">Caption</Label>
|
||||
<Input
|
||||
id="caption"
|
||||
value={form.caption}
|
||||
onChange={(e) => update("caption", e.target.value)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Parent Page</Label>
|
||||
<ParentCombobox
|
||||
value={form.parentId}
|
||||
onChange={(id) => update("parentId", id)}
|
||||
pages={allPages}
|
||||
currentPageId={catalogPage.id}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Layout</Label>
|
||||
<LayoutSelect
|
||||
value={form.pageLayout}
|
||||
onChange={(v) => update("pageLayout", v)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{LAYOUT_DESCRIPTIONS[
|
||||
form.pageLayout as CatalogLayout
|
||||
] ?? "Custom layout"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="orderNum">Order</Label>
|
||||
<Input
|
||||
id="orderNum"
|
||||
type="number"
|
||||
value={form.orderNum}
|
||||
onChange={(e) =>
|
||||
update(
|
||||
"orderNum",
|
||||
parseInt(e.target.value, 10) || 0,
|
||||
)
|
||||
}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="minRank">Min Rank</Label>
|
||||
<Input
|
||||
id="minRank"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={form.minRank}
|
||||
onChange={(e) =>
|
||||
update("minRank", parseInt(e.target.value, 10) || 1)
|
||||
}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Texts ────────────────────────────────────── */}
|
||||
<TabsContent value="texts" className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Headline</Label>
|
||||
<CatalogImagePicker
|
||||
value={form.pageHeadline}
|
||||
onChange={(v) => update("pageHeadline", v)}
|
||||
type="header"
|
||||
label="Headline image"
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<ImagePreview value={form.pageHeadline} height={50} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Teaser</Label>
|
||||
<CatalogImagePicker
|
||||
value={form.pageTeaser}
|
||||
onChange={(v) => update("pageTeaser", v)}
|
||||
type="teaser"
|
||||
label="Teaser image"
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<ImagePreview value={form.pageTeaser} height={50} />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<TextareaField
|
||||
id="pageText1"
|
||||
label="Text 1"
|
||||
value={form.pageText1}
|
||||
onChange={(v) => update("pageText1", v)}
|
||||
disabled={!canEdit}
|
||||
rows={4}
|
||||
max={5000}
|
||||
/>
|
||||
<TextareaField
|
||||
id="pageText2"
|
||||
label="Text 2"
|
||||
value={form.pageText2}
|
||||
onChange={(v) => update("pageText2", v)}
|
||||
disabled={!canEdit}
|
||||
rows={4}
|
||||
max={5000}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<TextareaField
|
||||
id="pageTextDetails"
|
||||
label="Details"
|
||||
value={form.pageTextDetails}
|
||||
onChange={(v) => update("pageTextDetails", v)}
|
||||
disabled={!canEdit}
|
||||
rows={3}
|
||||
max={5000}
|
||||
/>
|
||||
<TextareaField
|
||||
id="pageTextTeaser"
|
||||
label="Text Teaser"
|
||||
value={form.pageTextTeaser}
|
||||
onChange={(v) => update("pageTextTeaser", v)}
|
||||
disabled={!canEdit}
|
||||
rows={3}
|
||||
max={5000}
|
||||
/>
|
||||
</div>
|
||||
<TextareaField
|
||||
id="pageSpecial"
|
||||
label="Special"
|
||||
value={form.pageSpecial}
|
||||
onChange={(v) => update("pageSpecial", v)}
|
||||
disabled={!canEdit}
|
||||
rows={2}
|
||||
max={2048}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Appearance ───────────────────────────────── */}
|
||||
<TabsContent value="appearance" className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Icon</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<IconPicker
|
||||
value={form.iconImage}
|
||||
onChange={(id) => update("iconImage", id)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Icon shown in the catalog tree navigation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="iconColor">Icon Color</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="iconColor"
|
||||
type="number"
|
||||
min={0}
|
||||
className="w-32"
|
||||
value={form.iconColor}
|
||||
onChange={(e) =>
|
||||
update(
|
||||
"iconColor",
|
||||
parseInt(e.target.value, 10) || 0,
|
||||
)
|
||||
}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Legacy tint value. Leave at default unless you need to
|
||||
match a specific theme.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Child pages list */}
|
||||
{childPages.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<FolderTree className="h-4 w-4" />
|
||||
Child Pages ({childPages.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-1">
|
||||
{childPages.map((child) => (
|
||||
<Link
|
||||
key={child.id}
|
||||
href={`/ase/economy/catalog/${child.id}`}
|
||||
className="flex items-center justify-between rounded-md px-3 py-2 hover:bg-accent text-sm"
|
||||
>
|
||||
<span>
|
||||
{child.caption}
|
||||
<span className="ml-2 text-muted-foreground font-mono text-xs">
|
||||
#{child.id}
|
||||
</span>
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
child.enabled === "1" ? "default" : "outline"
|
||||
}
|
||||
>
|
||||
{child.enabled === "1" ? "Enabled" : "Disabled"}
|
||||
</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Right column (1/3): status sticky ────────────────── */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card className="lg:sticky lg:top-4">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Status & Preview</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Preview */}
|
||||
<div className="flex items-center gap-3 rounded-md border bg-muted/30 p-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded bg-background shrink-0">
|
||||
<CatalogIcon iconImage={form.iconImage} size={36} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{form.caption || "Untitled"}
|
||||
</p>
|
||||
<Badge
|
||||
className={cn(
|
||||
"mt-1 h-5 px-1.5 text-[10px] font-mono border-0",
|
||||
layoutColorClass,
|
||||
)}
|
||||
>
|
||||
{form.pageLayout}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toggles */}
|
||||
<div className="space-y-3">
|
||||
<ToggleRow
|
||||
id="tg-enabled"
|
||||
label="Enabled"
|
||||
description="Active in the catalog"
|
||||
checked={form.enabled === "1"}
|
||||
onChange={(v) => update("enabled", v ? "1" : "0")}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<ToggleRow
|
||||
id="tg-visible"
|
||||
label="Visible"
|
||||
description="Shown in navigation"
|
||||
checked={form.visible === "1"}
|
||||
onChange={(v) => update("visible", v ? "1" : "0")}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<ToggleRow
|
||||
id="tg-club"
|
||||
label="Club Only"
|
||||
description="Requires HC subscription"
|
||||
checked={form.clubOnly === "1"}
|
||||
onChange={(v) => update("clubOnly", v ? "1" : "0")}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-4 border-t pt-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<FolderTree className="h-3.5 w-3.5" />
|
||||
{childPages.length} sub
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Package className="h-3.5 w-3.5" />#{catalogPage.id}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{canEdit && (
|
||||
<div className="space-y-2 border-t pt-3">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="w-full"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save & Sync
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={isPending}
|
||||
className="w-full"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete Page
|
||||
</Button>
|
||||
<p className="text-center text-[10px] text-muted-foreground">
|
||||
Press <kbd className="rounded border px-1">Ctrl+S</kbd> to
|
||||
save
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Toggle row ─────────────────────────────────────────────────
|
||||
|
||||
function ToggleRow({
|
||||
id,
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Label htmlFor={id} className="text-sm font-medium cursor-pointer">
|
||||
{label}
|
||||
</Label>
|
||||
<p className="text-[11px] text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<Switch
|
||||
id={id}
|
||||
checked={checked}
|
||||
onCheckedChange={onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TextareaField({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
rows = 3,
|
||||
max,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
disabled?: boolean;
|
||||
rows?: number;
|
||||
max?: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
{max && (
|
||||
<span className="text-[10px] tabular-nums text-muted-foreground">
|
||||
{value.length}/{max}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Textarea
|
||||
id={id}
|
||||
rows={rows}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
maxLength={max}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Layout select with color swatch ────────────────────────────
|
||||
|
||||
function LayoutSelect({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange} disabled={disabled}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CATALOG_LAYOUTS.map((layout) => {
|
||||
const colorClass =
|
||||
LAYOUT_COLORS[layout] || "bg-muted text-muted-foreground";
|
||||
return (
|
||||
<SelectItem key={layout} value={layout}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-2 w-2 rounded-full",
|
||||
colorClass,
|
||||
)}
|
||||
/>
|
||||
{layout}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Parent page combobox with search ────────────────────────────
|
||||
|
||||
function ParentCombobox({
|
||||
value,
|
||||
onChange,
|
||||
pages,
|
||||
currentPageId,
|
||||
disabled,
|
||||
}: {
|
||||
value: number;
|
||||
onChange: (id: number) => void;
|
||||
pages: { id: number; caption: string }[];
|
||||
currentPageId: number;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const options = pages.filter((p) => p.id !== currentPageId);
|
||||
const selected =
|
||||
value === -1
|
||||
? { id: -1, caption: "Root" }
|
||||
: pages.find((p) => p.id === value);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
{selected ? (
|
||||
<span className="truncate">
|
||||
<span className="text-muted-foreground font-mono text-xs mr-1.5">
|
||||
{selected.id === -1 ? "—" : `#${selected.id}`}
|
||||
</span>
|
||||
{selected.caption}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Select parent...</span>
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[--radix-popover-trigger-width] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search pages..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No page found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="root"
|
||||
onSelect={() => {
|
||||
onChange(-1);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === -1 ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span className="text-muted-foreground font-mono text-xs mr-1.5">
|
||||
—
|
||||
</span>
|
||||
Root
|
||||
</CommandItem>
|
||||
{options.map((page) => (
|
||||
<CommandItem
|
||||
key={page.id}
|
||||
value={`${page.id} ${page.caption}`}
|
||||
onSelect={() => {
|
||||
onChange(page.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === page.id ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span className="text-muted-foreground font-mono text-xs mr-1.5">
|
||||
#{page.id}
|
||||
</span>
|
||||
<span className="truncate">{page.caption}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,617 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Database,
|
||||
FileJson,
|
||||
Filter,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
Save,
|
||||
ShoppingCart,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { translateCatalogItems } from "@/actions/catalog-items";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import {
|
||||
habboGamedataHotelLabel,
|
||||
habboGamedataHotelShort,
|
||||
normalizeHabboGamedataHotel,
|
||||
} from "@/lib/habbo-gamedata-hotel";
|
||||
import { FurniIcon } from "./catalog-items-table";
|
||||
|
||||
interface BaseItemForTranslation {
|
||||
id: number;
|
||||
publicName: string;
|
||||
itemName: string;
|
||||
spriteId: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface CatalogTranslateTabProps {
|
||||
baseItems: BaseItemForTranslation[];
|
||||
catalogNameMap: Record<number, string>;
|
||||
canEdit: boolean;
|
||||
furniDataIdList: number[];
|
||||
furniDescriptionMap: Record<number, string>;
|
||||
furniRevisionMap?: Record<number, { classname: string; revision: number }>;
|
||||
/** CMS setting `habbo_gamedata_hotel` — which official Habbo locale to suggest from */
|
||||
gamedataHotel?: string;
|
||||
}
|
||||
|
||||
type FilterMode = "all" | "missing" | "modified";
|
||||
|
||||
export function CatalogTranslateTab({
|
||||
baseItems,
|
||||
catalogNameMap,
|
||||
canEdit,
|
||||
furniDataIdList,
|
||||
furniDescriptionMap,
|
||||
furniRevisionMap = {},
|
||||
gamedataHotel = "it",
|
||||
}: CatalogTranslateTabProps) {
|
||||
const router = useRouter();
|
||||
const { run, isPending } = useServerAction();
|
||||
const [isSuggesting, setIsSuggesting] = useState(false);
|
||||
const [filterMode, setFilterMode] = useState<FilterMode>("all");
|
||||
const [activeHotel, setActiveHotel] = useState(() =>
|
||||
normalizeHabboGamedataHotel(gamedataHotel),
|
||||
);
|
||||
useEffect(() => {
|
||||
setActiveHotel(normalizeHabboGamedataHotel(gamedataHotel));
|
||||
}, [gamedataHotel]);
|
||||
|
||||
// Always refresh from CMS settings so the button label tracks hotel changes
|
||||
// without requiring a full page remount after saving Settings.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function refreshHotel() {
|
||||
try {
|
||||
const res = await fetch("/api/admin/catalog/suggest", {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as { hotel?: string };
|
||||
if (!cancelled && typeof data.hotel === "string") {
|
||||
setActiveHotel(normalizeHabboGamedataHotel(data.hotel));
|
||||
}
|
||||
} catch {
|
||||
// keep prop/default
|
||||
}
|
||||
}
|
||||
void refreshHotel();
|
||||
const onFocus = () => void refreshHotel();
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === "visible") void refreshHotel();
|
||||
};
|
||||
window.addEventListener("focus", onFocus);
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener("focus", onFocus);
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const hotelShort = habboGamedataHotelShort(activeHotel);
|
||||
const hotelLabel = habboGamedataHotelLabel(activeHotel);
|
||||
|
||||
const furniDataIdSet = useMemo(
|
||||
() => new Set(furniDataIdList),
|
||||
[furniDataIdList],
|
||||
);
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
|
||||
// ── Editable state: names + descriptions ─────────────────────────
|
||||
const [editedNames, setEditedNames] = useState<Record<number, string>>(
|
||||
Object.fromEntries(baseItems.map((item) => [item.id, item.publicName])),
|
||||
);
|
||||
|
||||
const [editedDescs, setEditedDescs] = useState<Record<number, string>>(
|
||||
Object.fromEntries(
|
||||
baseItems.map((item) => [item.id, furniDescriptionMap[item.id] ?? ""]),
|
||||
),
|
||||
);
|
||||
|
||||
// Track which items have name or description changes
|
||||
const isNameChanged = useCallback(
|
||||
(item: BaseItemForTranslation) => editedNames[item.id] !== item.publicName,
|
||||
[editedNames],
|
||||
);
|
||||
|
||||
const isDescChanged = useCallback(
|
||||
(item: BaseItemForTranslation) =>
|
||||
editedDescs[item.id] !== (furniDescriptionMap[item.id] ?? ""),
|
||||
[editedDescs, furniDescriptionMap],
|
||||
);
|
||||
|
||||
const changedItems = useMemo(
|
||||
() =>
|
||||
baseItems.filter((item) => isNameChanged(item) || isDescChanged(item)),
|
||||
[baseItems, isNameChanged, isDescChanged],
|
||||
);
|
||||
|
||||
const missingItems = useMemo(
|
||||
() => baseItems.filter((item) => !furniDataIdSet.has(item.id)),
|
||||
[baseItems, furniDataIdSet],
|
||||
);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
switch (filterMode) {
|
||||
case "missing":
|
||||
return baseItems.filter((item) => !furniDataIdSet.has(item.id));
|
||||
case "modified":
|
||||
return baseItems.filter(
|
||||
(item) => isNameChanged(item) || isDescChanged(item),
|
||||
);
|
||||
default:
|
||||
return baseItems;
|
||||
}
|
||||
}, [baseItems, filterMode, furniDataIdSet, isNameChanged, isDescChanged]);
|
||||
|
||||
const toggleOne = useCallback((id: number) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleAll = useCallback(() => {
|
||||
setSelected((prev) =>
|
||||
prev.size === filteredItems.length
|
||||
? new Set()
|
||||
: new Set(filteredItems.map((i) => i.id)),
|
||||
);
|
||||
}, [filteredItems]);
|
||||
|
||||
// Items to act on: selected ones if any, otherwise all
|
||||
const targetItems = useMemo(() => {
|
||||
if (selected.size === 0) return baseItems;
|
||||
return baseItems.filter((i) => selected.has(i.id));
|
||||
}, [baseItems, selected]);
|
||||
|
||||
function handleNameChange(id: number, value: string) {
|
||||
setEditedNames((prev) => ({ ...prev, [id]: value }));
|
||||
}
|
||||
|
||||
function handleDescChange(id: number, value: string) {
|
||||
setEditedDescs((prev) => ({ ...prev, [id]: value }));
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
setEditedNames(
|
||||
Object.fromEntries(baseItems.map((item) => [item.id, item.publicName])),
|
||||
);
|
||||
setEditedDescs(
|
||||
Object.fromEntries(
|
||||
baseItems.map((item) => [item.id, furniDescriptionMap[item.id] ?? ""]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSuggestFromHabboFurni() {
|
||||
if (targetItems.length === 0) return;
|
||||
setIsSuggesting(true);
|
||||
|
||||
try {
|
||||
// Collect classnames from selected items (or all if none selected)
|
||||
const classnames = targetItems
|
||||
.map((item) => item.itemName)
|
||||
.filter(Boolean);
|
||||
if (classnames.length === 0) {
|
||||
toast.info("No classnames found to look up.");
|
||||
setIsSuggesting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Batch in chunks of 50 to avoid too-long URLs
|
||||
const CHUNK_SIZE = 50;
|
||||
const allSuggestions: Record<
|
||||
string,
|
||||
{ name: string; description: string }
|
||||
> = {};
|
||||
let suggestHotel = activeHotel;
|
||||
|
||||
for (let i = 0; i < classnames.length; i += CHUNK_SIZE) {
|
||||
const chunk = classnames.slice(i, i + CHUNK_SIZE);
|
||||
const res = await fetch(
|
||||
`/api/admin/catalog/suggest?classnames=${encodeURIComponent(chunk.join(","))}`,
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (typeof data.hotel === "string") {
|
||||
suggestHotel = normalizeHabboGamedataHotel(data.hotel);
|
||||
setActiveHotel(suggestHotel);
|
||||
}
|
||||
Object.assign(allSuggestions, data.suggestions || {});
|
||||
}
|
||||
}
|
||||
const suggestLabel = habboGamedataHotelLabel(suggestHotel);
|
||||
|
||||
// Apply suggestions to items that have a match (names + descriptions)
|
||||
let nameCount = 0;
|
||||
let descCount = 0;
|
||||
const newNames = { ...editedNames };
|
||||
const newDescs = { ...editedDescs };
|
||||
|
||||
for (const item of targetItems) {
|
||||
const suggestion = allSuggestions[item.itemName];
|
||||
if (!suggestion) continue;
|
||||
|
||||
if (suggestion.name && suggestion.name !== item.publicName) {
|
||||
newNames[item.id] = suggestion.name;
|
||||
nameCount++;
|
||||
}
|
||||
|
||||
const currentDesc = furniDescriptionMap[item.id] ?? "";
|
||||
if (suggestion.description && suggestion.description !== currentDesc) {
|
||||
newDescs[item.id] = suggestion.description;
|
||||
descCount++;
|
||||
}
|
||||
}
|
||||
|
||||
setEditedNames(newNames);
|
||||
setEditedDescs(newDescs);
|
||||
|
||||
const total = nameCount + descCount;
|
||||
if (total > 0) {
|
||||
const parts = [];
|
||||
if (nameCount > 0) parts.push(`${nameCount} name(s)`);
|
||||
if (descCount > 0) parts.push(`${descCount} description(s)`);
|
||||
toast.success(
|
||||
`${parts.join(" + ")} suggested from ${suggestLabel}. Review and save.`,
|
||||
);
|
||||
} else {
|
||||
toast.info(
|
||||
`No new suggestions from ${suggestLabel} (${Object.keys(allSuggestions).length} looked up, all already match).`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(`Suggest error: ${(err as Error).message}`);
|
||||
} finally {
|
||||
setIsSuggesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveAll() {
|
||||
if (changedItems.length === 0) return;
|
||||
|
||||
run(
|
||||
() =>
|
||||
translateCatalogItems({
|
||||
items: changedItems.map((item) => ({
|
||||
id: item.id,
|
||||
publicName: editedNames[item.id],
|
||||
description: editedDescs[item.id],
|
||||
})),
|
||||
}),
|
||||
{
|
||||
successMessage: "Translations saved.",
|
||||
errorMessage: "Failed to save translations.",
|
||||
onSuccess: (data) => {
|
||||
const d = data as Record<string, number | boolean>;
|
||||
const parts = [];
|
||||
if (d.namesUpdated && (d.namesUpdated as number) > 0)
|
||||
parts.push(`${d.namesUpdated} name(s) updated`);
|
||||
if (d.descriptionsUpdated && (d.descriptionsUpdated as number) > 0)
|
||||
parts.push(`${d.descriptionsUpdated} description(s) updated`);
|
||||
if (parts.length === 0)
|
||||
parts.push(`${d.updated ?? 0} item(s) processed`);
|
||||
if (d.furniDataUpdated) parts.push("FurnitureData.json synced");
|
||||
if (d.furniDataInserted && (d.furniDataInserted as number) > 0) {
|
||||
parts.push(
|
||||
`${d.furniDataInserted} new item(s) added to FurnitureData.json`,
|
||||
);
|
||||
}
|
||||
parts.push("emulator cache refreshed");
|
||||
toast.success(`${parts.join(" · ")}.`);
|
||||
router.refresh();
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<CardTitle>Translate Item Names</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Edit the public name and description of furniture items. Saving will
|
||||
update:
|
||||
</p>
|
||||
<div className="flex gap-3 mt-2 flex-wrap">
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Database className="h-3 w-3" /> items_base (name)
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<ShoppingCart className="h-3 w-3" /> catalogName (auto)
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<FileJson className="h-3 w-3" /> FurnitureData.json (name + desc)
|
||||
</span>
|
||||
</div>
|
||||
{missingItems.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 mt-2 text-xs text-[var(--admin-warning)] dark:text-[var(--admin-warning)]">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
<span>
|
||||
{missingItems.length} item(s) missing from FurnitureData.json —
|
||||
will be auto-inserted on save
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div className="flex gap-2 flex-shrink-0 flex-wrap justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSuggestFromHabboFurni}
|
||||
disabled={isSuggesting || isPending || targetItems.length === 0}
|
||||
title={`Suggest names + descriptions from ${hotelLabel}. Change hotel in Admin → Settings → Game assets.`}
|
||||
>
|
||||
{isSuggesting ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Suggest {hotelShort}
|
||||
{selected.size > 0 ? ` (${selected.size})` : ""}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
disabled={changedItems.length === 0 || isPending}
|
||||
>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveAll}
|
||||
disabled={isPending || changedItems.length === 0}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save All ({changedItems.length})
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Filter bar */}
|
||||
{baseItems.length > 0 && (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant={filterMode === "all" ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setFilterMode("all")}
|
||||
>
|
||||
All ({baseItems.length})
|
||||
</Button>
|
||||
<Button
|
||||
variant={filterMode === "missing" ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setFilterMode("missing")}
|
||||
disabled={missingItems.length === 0}
|
||||
>
|
||||
<AlertTriangle className="mr-1 h-3 w-3" />
|
||||
Missing ({missingItems.length})
|
||||
</Button>
|
||||
<Button
|
||||
variant={filterMode === "modified" ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setFilterMode("modified")}
|
||||
disabled={changedItems.length === 0}
|
||||
>
|
||||
Modified ({changedItems.length})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{baseItems.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No items to translate in this page.
|
||||
</p>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No items match the current filter.
|
||||
</p>
|
||||
) : (
|
||||
<TooltipProvider>
|
||||
<div className="rounded-md border overflow-auto max-h-[600px]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px] px-2">
|
||||
<Checkbox
|
||||
checked={
|
||||
filteredItems.length > 0 &&
|
||||
selected.size === filteredItems.length
|
||||
}
|
||||
onCheckedChange={toggleAll}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
<TableHead className="w-[60px]">ID</TableHead>
|
||||
<TableHead className="w-[80px]">Type</TableHead>
|
||||
<TableHead>Item Name</TableHead>
|
||||
<TableHead className="min-w-[200px]">Public Name</TableHead>
|
||||
<TableHead className="min-w-[200px]">Description</TableHead>
|
||||
<TableHead className="w-[100px]">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredItems.map((item) => {
|
||||
const nameChanged = isNameChanged(item);
|
||||
const descChanged = isDescChanged(item);
|
||||
const isChanged = nameChanged || descChanged;
|
||||
const isMissing = !furniDataIdSet.has(item.id);
|
||||
const currentCatalogName = catalogNameMap[item.id];
|
||||
return (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={
|
||||
isChanged
|
||||
? "bg-[var(--admin-warning-subtle)] dark:bg-[var(--admin-warning-subtle)]"
|
||||
: isMissing
|
||||
? "bg-[var(--admin-warning-subtle)] dark:bg-[var(--admin-warning-subtle)]"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<TableCell className="px-2">
|
||||
<Checkbox
|
||||
checked={selected.has(item.id)}
|
||||
onCheckedChange={() => toggleOne(item.id)}
|
||||
aria-label={`Select ${item.itemName}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="px-1">
|
||||
{(() => {
|
||||
const revInfo = furniRevisionMap[item.id];
|
||||
return revInfo ? (
|
||||
<FurniIcon
|
||||
classname={revInfo.classname}
|
||||
revision={revInfo.revision}
|
||||
/>
|
||||
) : (
|
||||
<FurniIcon
|
||||
classname={item.itemName}
|
||||
revision={0}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{item.id}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
item.type === "s" ? "default" : "secondary"
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{item.type === "s"
|
||||
? "Floor"
|
||||
: item.type === "i"
|
||||
? "Wall"
|
||||
: item.type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground font-mono">
|
||||
{item.itemName}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={editedNames[item.id] || ""}
|
||||
onChange={(e) =>
|
||||
handleNameChange(item.id, e.target.value)
|
||||
}
|
||||
disabled={!canEdit}
|
||||
className={`h-8 text-sm ${nameChanged ? "border-[var(--admin-warning-border)] dark:border-[var(--admin-warning-border)]" : ""}`}
|
||||
/>
|
||||
{currentCatalogName &&
|
||||
currentCatalogName !== item.publicName && (
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5 truncate">
|
||||
catalog: {currentCatalogName}
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={editedDescs[item.id] || ""}
|
||||
onChange={(e) =>
|
||||
handleDescChange(item.id, e.target.value)
|
||||
}
|
||||
disabled={!canEdit}
|
||||
placeholder="No description"
|
||||
className={`h-8 text-sm ${descChanged ? "border-[var(--admin-warning-border)] dark:border-[var(--admin-warning-border)]" : ""}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isChanged ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-xs bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)]"
|
||||
>
|
||||
Modified
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
{isMissing ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-[var(--admin-warning)] flex-shrink-0" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Missing from FurnitureData.json</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Will be auto-inserted on save
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-[var(--admin-success)] flex-shrink-0" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
In FurnitureData.json
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { ImageOff, Loader2 } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { CATALOGUE_ASSET_BASE_PATH } from "@/lib/catalog-assets";
|
||||
|
||||
interface ImagePreviewProps {
|
||||
value: string;
|
||||
basePath?: string;
|
||||
height?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ImagePreview({
|
||||
value,
|
||||
basePath = CATALOGUE_ASSET_BASE_PATH,
|
||||
height = 60,
|
||||
className,
|
||||
}: ImagePreviewProps) {
|
||||
const [src, setSrc] = useState("");
|
||||
const [status, setStatus] = useState<"idle" | "loading" | "loaded" | "error">(
|
||||
"idle",
|
||||
);
|
||||
const [fallback, setFallback] = useState(false);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!value.trim()) {
|
||||
setSrc("");
|
||||
setStatus("idle");
|
||||
setFallback(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
setFallback(false);
|
||||
setSrc(`${basePath}/${value.trim()}.png`);
|
||||
setStatus("loading");
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [value, basePath]);
|
||||
|
||||
function handleError() {
|
||||
if (!fallback) {
|
||||
setFallback(true);
|
||||
setSrc(`${basePath}/${value.trim()}.gif`);
|
||||
setStatus("loading");
|
||||
} else {
|
||||
setStatus("error");
|
||||
}
|
||||
}
|
||||
|
||||
if (status === "idle" || !src) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-center rounded border border-dashed bg-muted/30 text-muted-foreground ${className || ""}`}
|
||||
style={{ height }}
|
||||
>
|
||||
<ImageOff className="h-4 w-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex items-center justify-center overflow-hidden rounded border bg-muted/30 ${className || ""}`}
|
||||
style={{ height }}
|
||||
>
|
||||
{status === "loading" && (
|
||||
<Loader2 className="absolute h-4 w-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
className="max-h-full max-w-full object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
onLoad={() => setStatus("loaded")}
|
||||
onError={handleError}
|
||||
/>
|
||||
{status === "error" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-muted/60">
|
||||
<ImageOff className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { Crown, Image as ImageIcon, Package, Sparkles } from "lucide-react";
|
||||
import {
|
||||
type CatalogItemData,
|
||||
getPointsLabel,
|
||||
} from "@/app/admin/catalog/[id]/catalog-items-table/types";
|
||||
} from "@/components/admin/catalog/detail/catalog-items-table/types";
|
||||
import {
|
||||
CurrencyIcon,
|
||||
currencyKindFromPointsType,
|
||||
|
||||
@@ -214,8 +214,8 @@ export async function Navigation({ session }: { session: Session | null }) {
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{showMod ? (
|
||||
<Link href="/mod" className="nav-item">
|
||||
{showAdmin || showMod ? (
|
||||
<Link href="/ase" className="nav-item">
|
||||
<Image
|
||||
src="/assets/images/icons/navigation/home.png"
|
||||
alt=""
|
||||
@@ -223,20 +223,7 @@ export async function Navigation({ session }: { session: Session | null }) {
|
||||
height={20}
|
||||
className="mr-1.5"
|
||||
/>
|
||||
{t("mod")}
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
{showAdmin ? (
|
||||
<Link href="/admin" className="nav-item">
|
||||
<Image
|
||||
src="/assets/images/icons/navigation/home.png"
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
className="mr-1.5"
|
||||
/>
|
||||
{t("admin")}
|
||||
ASE
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -409,8 +396,8 @@ export async function Navigation({ session }: { session: Session | null }) {
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{showMod ? (
|
||||
<Link href="/mod" className="nav-item">
|
||||
{showAdmin || showMod ? (
|
||||
<Link href="/ase" className="nav-item">
|
||||
<Image
|
||||
src="/assets/images/icons/navigation/home.png"
|
||||
alt=""
|
||||
@@ -418,20 +405,7 @@ export async function Navigation({ session }: { session: Session | null }) {
|
||||
height={20}
|
||||
className="mr-1.5"
|
||||
/>
|
||||
{t("mod")}
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
{showAdmin ? (
|
||||
<Link href="/admin" className="nav-item">
|
||||
<Image
|
||||
src="/assets/images/icons/navigation/home.png"
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
className="mr-1.5"
|
||||
/>
|
||||
{t("admin")}
|
||||
ASE
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
|
||||
+36
-14
@@ -11,6 +11,7 @@ import {
|
||||
datetime,
|
||||
decimal,
|
||||
double,
|
||||
index,
|
||||
int,
|
||||
longtext,
|
||||
mediumtext,
|
||||
@@ -1149,21 +1150,42 @@ export const UserWatch = mysqlTable(
|
||||
],
|
||||
);
|
||||
|
||||
export const AdminAuditLog = mysqlTable("admin_audit_log", {
|
||||
id: int("id").autoincrement().primaryKey().notNull(),
|
||||
userId: int("user_id").notNull(),
|
||||
action: varchar("action", { length: 191 }).notNull().default(""),
|
||||
target: varchar("target", { length: 191 }).notNull().default(""),
|
||||
targetId: int("target_id"),
|
||||
details: text("details"),
|
||||
before: text("before"),
|
||||
after: text("after"),
|
||||
diff: text("diff"),
|
||||
ipAddress: varchar("ip_address", { length: 45 }),
|
||||
createdAt: varchar("created_at", { length: 64 }).notNull().default(""),
|
||||
updatedAt: varchar("updated_at", { length: 64 }),
|
||||
});
|
||||
export const AdminAuditLog = mysqlTable(
|
||||
"admin_audit_log",
|
||||
{
|
||||
id: int("id").autoincrement().primaryKey().notNull(),
|
||||
userId: int("user_id").notNull(),
|
||||
action: varchar("action", { length: 191 }).notNull().default(""),
|
||||
target: varchar("target", { length: 191 }).notNull().default(""),
|
||||
targetId: int("target_id"),
|
||||
details: text("details"),
|
||||
before: text("before"),
|
||||
after: text("after"),
|
||||
diff: text("diff"),
|
||||
ipAddress: varchar("ip_address", { length: 45 }),
|
||||
correlationId: varchar("correlation_id", { length: 64 }),
|
||||
outcome: varchar("outcome", { length: 32 }),
|
||||
reason: text("reason"),
|
||||
domain: varchar("domain", { length: 32 }),
|
||||
createdAt: varchar("created_at", { length: 64 }).notNull().default(""),
|
||||
updatedAt: varchar("updated_at", { length: 64 }),
|
||||
},
|
||||
(t) => [index("admin_audit_log_correlation_id_idx").on(t.correlationId)],
|
||||
);
|
||||
|
||||
export const HousekeepingUserPreferences = mysqlTable(
|
||||
"housekeeping_user_preferences",
|
||||
{
|
||||
userId: int("user_id").primaryKey().notNull(),
|
||||
schemaVersion: int("schema_version").notNull().default(1),
|
||||
payload: longtext("payload").notNull(),
|
||||
createdAt: datetime("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: datetime("updated_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
);
|
||||
|
||||
export type HousekeepingUserPreferenceRow =
|
||||
typeof HousekeepingUserPreferences.$inferSelect;
|
||||
export const AclRole = mysqlTable("acl_roles", {
|
||||
id: int("id").autoincrement().primaryKey().notNull(),
|
||||
slug: varchar("slug", { length: 191 }).unique().notNull(),
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import "server-only";
|
||||
|
||||
import { CONTENT_COMMANDS } from "./domains/content/commands/content-commands";
|
||||
import { ECONOMY_COMMANDS } from "./domains/economy/commands/economy-commands";
|
||||
import { HOTEL_COMMANDS } from "./domains/hotel/commands/hotel-commands";
|
||||
import { STUDIO_COMMANDS } from "./domains/hotel/commands/studio-commands";
|
||||
import { COMMUNITY_COMMANDS } from "./domains/people/commands/community-commands";
|
||||
import { MODERATION_COMMANDS } from "./domains/people/commands/moderation-commands";
|
||||
import { SUPPORT_COMMANDS } from "./domains/people/commands/support-commands";
|
||||
import { USER_COMMANDS } from "./domains/people/commands/user-commands";
|
||||
import { SYSTEM_COMMANDS } from "./domains/system/commands/system-commands";
|
||||
import {
|
||||
defineHousekeepingCommands,
|
||||
registerHousekeepingCommands,
|
||||
} from "./foundation/commands/bootstrap";
|
||||
import { sealHousekeepingCommandRegistry } from "./foundation/commands/registry";
|
||||
|
||||
const currentHousekeepingCommands = defineHousekeepingCommands(
|
||||
...CONTENT_COMMANDS,
|
||||
...ECONOMY_COMMANDS,
|
||||
...HOTEL_COMMANDS,
|
||||
...STUDIO_COMMANDS,
|
||||
...USER_COMMANDS,
|
||||
...COMMUNITY_COMMANDS,
|
||||
...SUPPORT_COMMANDS,
|
||||
...MODERATION_COMMANDS,
|
||||
...SYSTEM_COMMANDS,
|
||||
);
|
||||
|
||||
registerHousekeepingCommands(currentHousekeepingCommands);
|
||||
sealHousekeepingCommandRegistry();
|
||||
|
||||
export const housekeepingCommandRegistryReady = true;
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createHousekeepingRegistry } from "../foundation/registry";
|
||||
import { HOUSEKEEPING_MANIFESTS } from "../manifests";
|
||||
import {
|
||||
discoverLegacyPages,
|
||||
recordedLegacyPages,
|
||||
} from "../migration/discover-legacy-pages";
|
||||
import { HOUSEKEEPING_MIGRATION_MATRIX } from "../migration/matrix";
|
||||
import { HOUSEKEEPING_ROUTE_HANDLERS } from "../route-handlers";
|
||||
import { verifyHousekeepingRuntimeParity } from "./parity";
|
||||
|
||||
describe("Housekeeping runtime parity", () => {
|
||||
const report = verifyHousekeepingRuntimeParity(
|
||||
HOUSEKEEPING_MIGRATION_MATRIX,
|
||||
createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS),
|
||||
HOUSEKEEPING_ROUTE_HANDLERS,
|
||||
);
|
||||
|
||||
it("closes all 138 recorded migration rows while stable routes coexist", () => {
|
||||
expect(discoverLegacyPages()).toEqual(
|
||||
recordedLegacyPages(HOUSEKEEPING_MIGRATION_MATRIX),
|
||||
);
|
||||
expect(report).toEqual({
|
||||
discovered: 138,
|
||||
mapped: 138,
|
||||
verified: 138,
|
||||
removed: 2,
|
||||
unresolved: [],
|
||||
capabilityGaps: [],
|
||||
handlerGaps: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps every retained target under /ase-next with concrete parity evidence", () => {
|
||||
for (const row of HOUSEKEEPING_MIGRATION_MATRIX) {
|
||||
if (row.decision === "REMOVE") {
|
||||
expect(row.targetPath, row.legacyPath).toBeNull();
|
||||
expect(row.status, row.legacyPath).toBe("REMOVED");
|
||||
expect(row.parityEvidence, row.legacyPath).toContain(
|
||||
`runtime-parity:${row.legacyPath}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
expect(row.targetPath, row.legacyPath).toMatch(/^\/ase-next(?:\/|$)/);
|
||||
expect(row.status, row.legacyPath).toBe("VERIFIED");
|
||||
expect(row.parityEvidence, row.legacyPath).toContain(
|
||||
`runtime-parity:${row.legacyPath}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { HousekeepingRegistry } from "../foundation/registry";
|
||||
import type { MigrationEntry } from "../migration/types";
|
||||
import type { HousekeepingRouteHandler } from "../route-handlers";
|
||||
|
||||
export interface HousekeepingParityReport {
|
||||
readonly discovered: 138;
|
||||
readonly mapped: number;
|
||||
readonly verified: number;
|
||||
readonly removed: number;
|
||||
readonly unresolved: readonly string[];
|
||||
readonly capabilityGaps: readonly string[];
|
||||
readonly handlerGaps: readonly string[];
|
||||
}
|
||||
|
||||
function expectedEvidence(row: MigrationEntry): string {
|
||||
return `runtime-parity:${row.legacyPath}`;
|
||||
}
|
||||
|
||||
function capabilityMatches(
|
||||
row: MigrationEntry,
|
||||
route: HousekeepingRegistry["domains"][number]["routes"][number],
|
||||
): boolean {
|
||||
const declared = new Set(row.capabilities.read);
|
||||
if (route.capability.mode === "all") {
|
||||
return route.capability.slugs.every((slug) => declared.has(slug));
|
||||
}
|
||||
return route.capability.slugs.some((slug) => declared.has(slug));
|
||||
}
|
||||
|
||||
export function verifyHousekeepingRuntimeParity(
|
||||
matrix: readonly MigrationEntry[],
|
||||
registry: HousekeepingRegistry,
|
||||
handlers: readonly HousekeepingRouteHandler[],
|
||||
): HousekeepingParityReport {
|
||||
if (matrix.length !== 138) {
|
||||
throw new Error(
|
||||
`Housekeeping parity requires exactly 138 discovered rows; received ${matrix.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
const routes = registry.domains.flatMap((domain) => domain.routes);
|
||||
const routesByHref = new Map<string, (typeof routes)[number]>(
|
||||
routes.map((route) => [route.href, route]),
|
||||
);
|
||||
const routesById = new Map(routes.map((route) => [route.id, route]));
|
||||
const handlersByRouteId = new Map<string, HousekeepingRouteHandler[]>();
|
||||
for (const handler of handlers) {
|
||||
const matching = handlersByRouteId.get(handler.routeId) ?? [];
|
||||
matching.push(handler);
|
||||
handlersByRouteId.set(handler.routeId, matching);
|
||||
}
|
||||
|
||||
const unresolved = new Set<string>();
|
||||
const capabilityGaps = new Set<string>();
|
||||
const handlerGaps = new Set<string>();
|
||||
let mapped = 0;
|
||||
let verified = 0;
|
||||
let removed = 0;
|
||||
|
||||
for (const row of matrix) {
|
||||
let rowMapped = false;
|
||||
let rowCapabilityVerified = true;
|
||||
let rowHandlerVerified = true;
|
||||
|
||||
if (row.decision === "REMOVE") {
|
||||
removed += 1;
|
||||
rowMapped = row.targetPath === null;
|
||||
if (!rowMapped)
|
||||
unresolved.add(`${row.legacyPath}: removed target exists`);
|
||||
} else if (row.targetPath === null) {
|
||||
unresolved.add(`${row.legacyPath}: retained target is missing`);
|
||||
} else {
|
||||
const route = routesByHref.get(row.targetPath);
|
||||
if (!route) {
|
||||
unresolved.add(
|
||||
`${row.legacyPath}: no registered route for ${row.targetPath}`,
|
||||
);
|
||||
} else {
|
||||
rowMapped = true;
|
||||
if (!capabilityMatches(row, route)) {
|
||||
rowCapabilityVerified = false;
|
||||
capabilityGaps.add(
|
||||
`${row.legacyPath}: ${row.targetPath} capability mismatch`,
|
||||
);
|
||||
}
|
||||
|
||||
const matchingHandlers = handlersByRouteId.get(route.id) ?? [];
|
||||
if (matchingHandlers.length !== 1) {
|
||||
rowHandlerVerified = false;
|
||||
handlerGaps.add(
|
||||
`${row.legacyPath}: ${route.id} has ${matchingHandlers.length} handlers`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (rowMapped) mapped += 1;
|
||||
const expectedStatus = row.decision === "REMOVE" ? "REMOVED" : "VERIFIED";
|
||||
if (
|
||||
rowMapped &&
|
||||
rowCapabilityVerified &&
|
||||
rowHandlerVerified &&
|
||||
row.status === expectedStatus &&
|
||||
row.parityEvidence.includes(expectedEvidence(row))
|
||||
) {
|
||||
verified += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (const route of routes) {
|
||||
const matchingHandlers = handlersByRouteId.get(route.id) ?? [];
|
||||
if (matchingHandlers.length !== 1) {
|
||||
handlerGaps.add(
|
||||
`registry route ${route.id} has ${matchingHandlers.length} handlers`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const handler of handlers) {
|
||||
if (!routesById.has(handler.routeId)) {
|
||||
handlerGaps.add(`orphan handler ${handler.routeId}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
discovered: 138,
|
||||
mapped,
|
||||
verified,
|
||||
removed,
|
||||
unresolved: [...unresolved].sort(),
|
||||
capabilityGaps: [...capabilityGaps].sort(),
|
||||
handlerGaps: [...handlerGaps].sort(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const PREVIEW_ENTRYPOINTS = [
|
||||
"src/app/ase-next/layout.tsx",
|
||||
"src/app/ase-next/page.tsx",
|
||||
"src/app/ase-next/[domain]/layout.tsx",
|
||||
"src/app/ase-next/[domain]/[[...segments]]/page.tsx",
|
||||
"src/features/housekeeping/route-handlers.ts",
|
||||
] as const;
|
||||
|
||||
const STABLE_UI_ROOTS = ["src/app/admin", "src/app/mod"] as const;
|
||||
const PREMATURE_UI_ROOTS = [["src/app/admin", "-next"].join(""), ["src/app", "ase"].join("/")] as const;
|
||||
const STABLE_GLOBAL_SOURCES = [
|
||||
"src/lib/admin/guard.ts",
|
||||
"src/lib/proxy-access.ts",
|
||||
"src/components/navigation.tsx",
|
||||
"src/components/top-header.tsx",
|
||||
] as const;
|
||||
|
||||
describe("gated Housekeeping preview coexistence", () => {
|
||||
it("publishes the /ase-next entrypoints and dispatcher", () => {
|
||||
for (const path of PREVIEW_ENTRYPOINTS) {
|
||||
expect(existsSync(path), path).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves the stable /admin and /mod surfaces during preview", () => {
|
||||
for (const path of STABLE_UI_ROOTS) {
|
||||
expect(existsSync(path), path).toBe(true);
|
||||
}
|
||||
expect(existsSync("src/app/api/admin/csrf/route.ts")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not expose either superseded preview tree", () => {
|
||||
for (const path of PREMATURE_UI_ROOTS) {
|
||||
expect(existsSync(path), path).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the preview environment-gated outside production", () => {
|
||||
expect(
|
||||
existsSync("src/features/housekeeping/foundation/preview-gate.ts"),
|
||||
).toBe(true);
|
||||
for (const path of ["src/env.ts", ".env.example"] as const) {
|
||||
expect(readFileSync(path, "utf8"), path).toContain(
|
||||
"HOUSEKEEPING_NEXT_PREVIEW_ENABLED",
|
||||
);
|
||||
}
|
||||
const gate = readFileSync(
|
||||
"src/features/housekeeping/foundation/preview-gate.ts",
|
||||
"utf8",
|
||||
);
|
||||
expect(gate).toContain('input.nodeEnv !== "production"');
|
||||
expect(gate).toContain("input.flag");
|
||||
});
|
||||
|
||||
it("leaves stable global links and authorization fallbacks on /admin and /mod", () => {
|
||||
for (const path of STABLE_GLOBAL_SOURCES) {
|
||||
expect(readFileSync(path, "utf8"), path).not.toContain("/ase-next");
|
||||
}
|
||||
expect(readFileSync("src/components/top-header.tsx", "utf8")).toContain(
|
||||
'href="/admin"',
|
||||
);
|
||||
expect(readFileSync("src/components/top-header.tsx", "utf8")).toContain(
|
||||
'href="/mod"',
|
||||
);
|
||||
});
|
||||
|
||||
it("does not add global redirects into or out of the preview namespace", () => {
|
||||
for (const path of ["next.config.ts", "src/proxy.ts"] as const) {
|
||||
const source = readFileSync(path, "utf8");
|
||||
expect(source, path).not.toMatch(
|
||||
/(?:source|destination|redirect)\s*[:(][^\n]*\/ase-next/,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, relative, resolve, sep } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const housekeepingRoot = resolve(process.cwd(), "src/features/housekeeping");
|
||||
|
||||
function sourceFiles(directory: string): string[] {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = resolve(directory, entry.name);
|
||||
if (entry.isDirectory()) return sourceFiles(path);
|
||||
if (!/\.(?:ts|tsx)$/u.test(entry.name) || /\.test\./u.test(entry.name)) {
|
||||
return [];
|
||||
}
|
||||
return [path];
|
||||
});
|
||||
}
|
||||
|
||||
function moduleSpecifiers(source: string): string[] {
|
||||
return [
|
||||
...source.matchAll(
|
||||
/(?:from\s+|import\s*\(|import\s+|require\s*\()\s*["']([^"']+)["']/gu,
|
||||
),
|
||||
].flatMap((match) => (match[1] ? [match[1]] : []));
|
||||
}
|
||||
|
||||
function resolveHousekeepingImport(
|
||||
file: string,
|
||||
specifier: string,
|
||||
): string | null {
|
||||
if (specifier.startsWith(".")) {
|
||||
return resolve(dirname(file), specifier).replaceAll("\\", "/");
|
||||
}
|
||||
const prefix = "@/features/housekeeping/";
|
||||
if (specifier.startsWith(prefix)) {
|
||||
return resolve(housekeepingRoot, specifier.slice(prefix.length)).replaceAll(
|
||||
"\\",
|
||||
"/",
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function displayPath(path: string): string {
|
||||
return relative(process.cwd(), path).split(sep).join("/");
|
||||
}
|
||||
|
||||
describe("Housekeeping source boundaries", () => {
|
||||
it("keeps foundation independent from domain internals, database, and actions", () => {
|
||||
const violations = sourceFiles(resolve(housekeepingRoot, "foundation"))
|
||||
.flatMap((file) =>
|
||||
moduleSpecifiers(readFileSync(file, "utf8")).flatMap((specifier) => {
|
||||
const resolved = resolveHousekeepingImport(file, specifier);
|
||||
const importsDomain =
|
||||
resolved?.includes("/features/housekeeping/domains/") === true;
|
||||
const importsInfrastructure =
|
||||
specifier === "@/db" ||
|
||||
specifier.startsWith("@/db/") ||
|
||||
specifier === "@/actions" ||
|
||||
specifier.startsWith("@/actions/");
|
||||
return importsDomain || importsInfrastructure
|
||||
? [`${displayPath(file)} -> ${specifier}`]
|
||||
: [];
|
||||
}),
|
||||
)
|
||||
.sort();
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it("prevents one domain from importing another domain's internals", () => {
|
||||
const domainsRoot = resolve(housekeepingRoot, "domains");
|
||||
const violations = sourceFiles(domainsRoot)
|
||||
.flatMap((file) => {
|
||||
const owner = relative(domainsRoot, file).split(sep)[0];
|
||||
return moduleSpecifiers(readFileSync(file, "utf8")).flatMap(
|
||||
(specifier) => {
|
||||
const resolved = resolveHousekeepingImport(file, specifier);
|
||||
if (!resolved) return [];
|
||||
const marker = "/features/housekeeping/domains/";
|
||||
const offset = resolved.indexOf(marker);
|
||||
if (offset < 0) return [];
|
||||
const target = resolved.slice(offset + marker.length).split("/")[0];
|
||||
return target && target !== owner
|
||||
? [`${displayPath(file)} -> ${specifier}`]
|
||||
: [];
|
||||
},
|
||||
);
|
||||
})
|
||||
.sort();
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||
import { CONTENT_COMMAND_IDS, createContentCommands } from "./content-commands";
|
||||
|
||||
const expected = [
|
||||
["content.editorial.article.change", "article.change", PERMS.NEWS_EDIT],
|
||||
["content.media.ad.change", "ad.change", PERMS.PAGES_EDIT],
|
||||
["content.media.banner.change", "banner.change", PERMS.BANNERS_EDIT],
|
||||
[
|
||||
"content.engagement.event-type.change",
|
||||
"event-type.change",
|
||||
PERMS.EVENTS_EDIT,
|
||||
],
|
||||
["content.engagement.event.change", "event.change", PERMS.EVENTS_EDIT],
|
||||
[
|
||||
"content.engagement.event-prize.change",
|
||||
"event-prize.change",
|
||||
PERMS.EVENTS_EDIT,
|
||||
],
|
||||
[
|
||||
"content.engagement.event-winner.add",
|
||||
"event-winner.add",
|
||||
PERMS.EVENTS_EDIT,
|
||||
],
|
||||
["content.engagement.poll.change", "poll.change", PERMS.POLLS_EDIT],
|
||||
[
|
||||
"content.engagement.poll-question.change",
|
||||
"poll-question.change",
|
||||
PERMS.POLLS_EDIT,
|
||||
],
|
||||
["content.media.photo.delete", "photo.delete", PERMS.PAGES_EDIT],
|
||||
["content.media.asset.upload", "media.upload", PERMS.PAGES_EDIT],
|
||||
["content.media.asset.delete", "media.delete", PERMS.PAGES_EDIT],
|
||||
["content.editorial.tag.change", "tag.change", PERMS.PAGES_EDIT],
|
||||
["content.engagement.prefix.change", "prefix.change", PERMS.PREFIXES_EDIT],
|
||||
[
|
||||
"content.engagement.prefix-blacklist.change",
|
||||
"prefix-blacklist.change",
|
||||
PERMS.PREFIXES_EDIT,
|
||||
],
|
||||
[
|
||||
"content.engagement.prefix-settings.update",
|
||||
"prefix-settings.update",
|
||||
PERMS.PREFIXES_EDIT,
|
||||
],
|
||||
["content.help.question.change", "help-question.change", PERMS.PAGES_EDIT],
|
||||
[
|
||||
"content.editorial.writeable-box.change",
|
||||
"writeable-box.change",
|
||||
PERMS.PAGES_EDIT,
|
||||
],
|
||||
[
|
||||
"content.help.email-template.change",
|
||||
"email-template.change",
|
||||
PERMS.PAGES_EDIT,
|
||||
],
|
||||
["content.brand.theme.update", "theme.update", PERMS.SETTINGS_EDIT],
|
||||
[
|
||||
"content.brand.theme.apply-preset",
|
||||
"theme.apply-preset",
|
||||
PERMS.SETTINGS_EDIT,
|
||||
],
|
||||
[
|
||||
"content.brand.theme.custom-change",
|
||||
"theme.custom-change",
|
||||
PERMS.SETTINGS_EDIT,
|
||||
],
|
||||
[
|
||||
"content.brand.theme.apply-custom",
|
||||
"theme.apply-custom",
|
||||
PERMS.SETTINGS_EDIT,
|
||||
],
|
||||
["content.brand.favicon.save", "favicon.save", PERMS.SETTINGS_EDIT],
|
||||
["content.brand.favicon.delete", "favicon.delete", PERMS.SETTINGS_EDIT],
|
||||
["content.brand.logo.save", "logo.save", PERMS.SETTINGS_EDIT],
|
||||
[
|
||||
"content.localization.cms.save",
|
||||
"translation.cms.save",
|
||||
PERMS.SETTINGS_EDIT,
|
||||
],
|
||||
[
|
||||
"content.localization.client.save",
|
||||
"translation.client.save",
|
||||
PERMS.SETTINGS_EDIT,
|
||||
],
|
||||
[
|
||||
"content.localization.emulator.save",
|
||||
"translation.emulator.save",
|
||||
PERMS.SETTINGS_EDIT,
|
||||
],
|
||||
] as const;
|
||||
|
||||
function context(): HousekeepingCapabilityContext {
|
||||
return {
|
||||
actor: { id: 42, username: "operator", rank: 7 },
|
||||
isSuperAdmin: false,
|
||||
has: () => true,
|
||||
hasAny: () => true,
|
||||
hasAll: () => true,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Content commands", () => {
|
||||
it("registers the exact complete operation matrix with existing ACLs", () => {
|
||||
const service = { execute: vi.fn() };
|
||||
const commands = createContentCommands(service as never);
|
||||
|
||||
expect(CONTENT_COMMAND_IDS).toEqual(expected.map(([id]) => id));
|
||||
expect(
|
||||
commands.map((command) => [
|
||||
command.id,
|
||||
command.operation,
|
||||
command.capability.slugs[0],
|
||||
]),
|
||||
).toEqual(expected);
|
||||
expect(commands.every((command) => command.owner === "content")).toBe(true);
|
||||
});
|
||||
|
||||
it("marks global brand and localization writes sensitive with reason confirmation", () => {
|
||||
const commands = createContentCommands({ execute: vi.fn() } as never);
|
||||
const globalCommands = commands.filter(
|
||||
(command) =>
|
||||
command.id.startsWith("content.brand.") ||
|
||||
command.id.startsWith("content.localization."),
|
||||
);
|
||||
|
||||
expect(globalCommands.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
globalCommands.every(
|
||||
(command) => command.risk === "sensitive" && command.requiresReason,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
globalCommands.every(
|
||||
(command) =>
|
||||
command.capability.mode === "any" &&
|
||||
command.capability.slugs[0] === PERMS.SETTINGS_EDIT,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("executes the real mutation service with actor-bound authority", async () => {
|
||||
const execute = vi.fn(async () => ({
|
||||
ok: true as const,
|
||||
data: { before: null, after: { id: "1" } },
|
||||
correlationId: "command-correlation",
|
||||
}));
|
||||
const [command] = createContentCommands({ execute } as never);
|
||||
|
||||
const result = await command.execute(
|
||||
{
|
||||
capability: context(),
|
||||
correlationId: "command-correlation",
|
||||
ipAddress: "127.0.0.1",
|
||||
},
|
||||
{ action: "create", title: "Launch" },
|
||||
);
|
||||
|
||||
expect(execute).toHaveBeenCalledWith(
|
||||
{
|
||||
correlationId: "command-correlation",
|
||||
expectedActorId: 42,
|
||||
},
|
||||
"article.change",
|
||||
{ action: "create", title: "Launch" },
|
||||
);
|
||||
expect(result).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it("isolates command validation schemas from later caller mutation", () => {
|
||||
const commands = createContentCommands({ execute: vi.fn() } as never);
|
||||
for (const command of commands) {
|
||||
expect(command.input.safeParse(null).success, command.id).toBe(false);
|
||||
expect(command.rateLimit.attempts).toBeGreaterThan(0);
|
||||
expect(command.rateLimit.windowMs).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import "server-only";
|
||||
|
||||
import { z } from "zod";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingCommand } from "../../../foundation/commands/registry";
|
||||
import { anyCapability } from "../../../foundation/contracts";
|
||||
import {
|
||||
type ContentMutationOperation,
|
||||
type ContentMutationService,
|
||||
contentMutationService,
|
||||
} from "../services/mutations";
|
||||
|
||||
const CONTENT_COMMAND_DEFINITIONS = [
|
||||
["content.editorial.article.change", "article.change", PERMS.NEWS_EDIT],
|
||||
["content.media.ad.change", "ad.change", PERMS.PAGES_EDIT],
|
||||
["content.media.banner.change", "banner.change", PERMS.BANNERS_EDIT],
|
||||
[
|
||||
"content.engagement.event-type.change",
|
||||
"event-type.change",
|
||||
PERMS.EVENTS_EDIT,
|
||||
],
|
||||
["content.engagement.event.change", "event.change", PERMS.EVENTS_EDIT],
|
||||
[
|
||||
"content.engagement.event-prize.change",
|
||||
"event-prize.change",
|
||||
PERMS.EVENTS_EDIT,
|
||||
],
|
||||
[
|
||||
"content.engagement.event-winner.add",
|
||||
"event-winner.add",
|
||||
PERMS.EVENTS_EDIT,
|
||||
],
|
||||
["content.engagement.poll.change", "poll.change", PERMS.POLLS_EDIT],
|
||||
[
|
||||
"content.engagement.poll-question.change",
|
||||
"poll-question.change",
|
||||
PERMS.POLLS_EDIT,
|
||||
],
|
||||
["content.media.photo.delete", "photo.delete", PERMS.PAGES_EDIT],
|
||||
["content.media.asset.upload", "media.upload", PERMS.PAGES_EDIT],
|
||||
["content.media.asset.delete", "media.delete", PERMS.PAGES_EDIT],
|
||||
["content.editorial.tag.change", "tag.change", PERMS.PAGES_EDIT],
|
||||
["content.engagement.prefix.change", "prefix.change", PERMS.PREFIXES_EDIT],
|
||||
[
|
||||
"content.engagement.prefix-blacklist.change",
|
||||
"prefix-blacklist.change",
|
||||
PERMS.PREFIXES_EDIT,
|
||||
],
|
||||
[
|
||||
"content.engagement.prefix-settings.update",
|
||||
"prefix-settings.update",
|
||||
PERMS.PREFIXES_EDIT,
|
||||
],
|
||||
["content.help.question.change", "help-question.change", PERMS.PAGES_EDIT],
|
||||
[
|
||||
"content.editorial.writeable-box.change",
|
||||
"writeable-box.change",
|
||||
PERMS.PAGES_EDIT,
|
||||
],
|
||||
[
|
||||
"content.help.email-template.change",
|
||||
"email-template.change",
|
||||
PERMS.PAGES_EDIT,
|
||||
],
|
||||
["content.brand.theme.update", "theme.update", PERMS.SETTINGS_EDIT],
|
||||
[
|
||||
"content.brand.theme.apply-preset",
|
||||
"theme.apply-preset",
|
||||
PERMS.SETTINGS_EDIT,
|
||||
],
|
||||
[
|
||||
"content.brand.theme.custom-change",
|
||||
"theme.custom-change",
|
||||
PERMS.SETTINGS_EDIT,
|
||||
],
|
||||
[
|
||||
"content.brand.theme.apply-custom",
|
||||
"theme.apply-custom",
|
||||
PERMS.SETTINGS_EDIT,
|
||||
],
|
||||
["content.brand.favicon.save", "favicon.save", PERMS.SETTINGS_EDIT],
|
||||
["content.brand.favicon.delete", "favicon.delete", PERMS.SETTINGS_EDIT],
|
||||
["content.brand.logo.save", "logo.save", PERMS.SETTINGS_EDIT],
|
||||
[
|
||||
"content.localization.cms.save",
|
||||
"translation.cms.save",
|
||||
PERMS.SETTINGS_EDIT,
|
||||
],
|
||||
[
|
||||
"content.localization.client.save",
|
||||
"translation.client.save",
|
||||
PERMS.SETTINGS_EDIT,
|
||||
],
|
||||
[
|
||||
"content.localization.emulator.save",
|
||||
"translation.emulator.save",
|
||||
PERMS.SETTINGS_EDIT,
|
||||
],
|
||||
] as const;
|
||||
|
||||
export const CONTENT_COMMAND_IDS = CONTENT_COMMAND_DEFINITIONS.map(
|
||||
([id]) => id,
|
||||
) as ReadonlyArray<(typeof CONTENT_COMMAND_DEFINITIONS)[number][0]>;
|
||||
|
||||
type ContentCommand = HousekeepingCommand<Record<string, unknown>, unknown> & {
|
||||
readonly operation: ContentMutationOperation;
|
||||
};
|
||||
|
||||
const commandInput = z.object({}).catchall(z.unknown());
|
||||
|
||||
export function createContentCommands(
|
||||
service: Pick<ContentMutationService, "execute">,
|
||||
): readonly ContentCommand[] {
|
||||
return CONTENT_COMMAND_DEFINITIONS.map(
|
||||
([id, operation, permission]): ContentCommand => ({
|
||||
id,
|
||||
owner: "content",
|
||||
operation,
|
||||
risk: "sensitive",
|
||||
capability: anyCapability(permission),
|
||||
input: commandInput,
|
||||
requiresReason:
|
||||
id.startsWith("content.brand.") ||
|
||||
id.startsWith("content.localization."),
|
||||
rateLimit: { attempts: 10, windowMs: 60_000 },
|
||||
execute: (context, input) =>
|
||||
service.execute(
|
||||
{
|
||||
correlationId: context.correlationId,
|
||||
expectedActorId: context.capability.actor.id,
|
||||
},
|
||||
operation,
|
||||
input,
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export const CONTENT_COMMANDS = createContentCommands(contentMutationService);
|
||||
@@ -0,0 +1,187 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingCapabilityContext } from "../../foundation/contracts";
|
||||
import { loadContentInboxItems } from "./inbox-production";
|
||||
import { loadContentSearchCandidates } from "./search-production";
|
||||
import { loadContentWidget } from "./widgets-production";
|
||||
|
||||
const { run } = vi.hoisted(() => ({ run: vi.fn() }));
|
||||
|
||||
vi.mock("./queries/content-queries", () => ({
|
||||
contentQuery: { run },
|
||||
}));
|
||||
|
||||
const context = {
|
||||
actor: { id: 42, username: "operator", rank: 7 },
|
||||
isSuperAdmin: false,
|
||||
has: () => true,
|
||||
hasAny: () => true,
|
||||
hasAll: () => true,
|
||||
} satisfies HousekeepingCapabilityContext;
|
||||
|
||||
function capability(granted: readonly string[]): HousekeepingCapabilityContext {
|
||||
const permissions = new Set(granted);
|
||||
return {
|
||||
...context,
|
||||
has: (slug) => permissions.has(slug),
|
||||
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
||||
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
||||
};
|
||||
}
|
||||
|
||||
function result(
|
||||
routeId: string,
|
||||
total: number,
|
||||
items: readonly Record<string, unknown>[] = [],
|
||||
) {
|
||||
return {
|
||||
ok: true as const,
|
||||
data: {
|
||||
kind: routeId.split(".")[1],
|
||||
items,
|
||||
total,
|
||||
partialDependencies: [],
|
||||
},
|
||||
correlationId: "provider-production",
|
||||
};
|
||||
}
|
||||
|
||||
describe("Content production providers", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
run.mockImplementation(async (_context, input) =>
|
||||
result(input.routeId, input.routeId.includes("articles") ? 7 : 3),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns only truthful persisted counts from editorial and localization widgets", async () => {
|
||||
const signal = new AbortController().signal;
|
||||
await expect(
|
||||
loadContentWidget("editorial", context, signal),
|
||||
).resolves.toEqual({ articles: 7 });
|
||||
await expect(
|
||||
loadContentWidget("localization", context, signal),
|
||||
).resolves.toEqual({ stores: 3 });
|
||||
});
|
||||
|
||||
it("loads real search candidates from bounded query routes", async () => {
|
||||
run.mockImplementation(async (_context, input) =>
|
||||
result(input.routeId, 1, [
|
||||
{
|
||||
id: "9",
|
||||
title: "Launch",
|
||||
description: "Published",
|
||||
href: "/ase-next/content/editorial/articles/9",
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const candidates = await loadContentSearchCandidates(
|
||||
"articles",
|
||||
context,
|
||||
"launch",
|
||||
25,
|
||||
);
|
||||
expect(candidates).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "content.editorial.articles:9",
|
||||
href: "/ase-next/content/editorial/articles/9",
|
||||
}),
|
||||
]);
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
context,
|
||||
expect.objectContaining({
|
||||
list: { search: "launch", pageSize: 25, offset: 0 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("builds publication inbox items only from real query rows", async () => {
|
||||
run.mockImplementation(async (_context, input) =>
|
||||
result(input.routeId, 1, [
|
||||
{
|
||||
id: "5",
|
||||
title: "Release",
|
||||
status: "published",
|
||||
updatedAt: new Date().toISOString(),
|
||||
href: "/ase-next/content/editorial/articles/5",
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const items = await loadContentInboxItems(
|
||||
"publication",
|
||||
context,
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(items).toEqual([]);
|
||||
});
|
||||
|
||||
it("loads only capability-matched media counts", async () => {
|
||||
const result = await loadContentWidget(
|
||||
"media",
|
||||
capability([PERMS.BANNERS_VIEW]),
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(result).toEqual({ banners: 3 });
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ routeId: "content.media.banners" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not invent a zero when a widget dependency is unavailable", async () => {
|
||||
run.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: { code: "DEPENDENCY_UNAVAILABLE", messageKey: "dependency" },
|
||||
correlationId: "unavailable",
|
||||
});
|
||||
await expect(
|
||||
loadContentWidget("editorial", context, new AbortController().signal),
|
||||
).rejects.toThrow("Content widget query unavailable");
|
||||
});
|
||||
|
||||
it("marks an inbox dependency unavailable instead of returning an available empty list", async () => {
|
||||
run.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: { code: "DEPENDENCY_UNAVAILABLE", messageKey: "dependency" },
|
||||
correlationId: "unavailable",
|
||||
});
|
||||
await expect(
|
||||
loadContentInboxItems(
|
||||
"publication",
|
||||
context,
|
||||
new AbortController().signal,
|
||||
),
|
||||
).rejects.toThrow("Content inbox query unavailable");
|
||||
});
|
||||
|
||||
it("emits only actionable publication statuses", async () => {
|
||||
run.mockResolvedValueOnce(
|
||||
result("content.editorial.articles", 2, [
|
||||
{
|
||||
id: "draft",
|
||||
title: "Draft",
|
||||
status: "draft",
|
||||
updatedAt: new Date().toISOString(),
|
||||
href: "/ase-next/content/editorial/articles/draft",
|
||||
},
|
||||
{
|
||||
id: "published",
|
||||
title: "Published",
|
||||
status: "published",
|
||||
updatedAt: new Date().toISOString(),
|
||||
href: "/ase-next/content/editorial/articles/published",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const items = await loadContentInboxItems(
|
||||
"publication",
|
||||
context,
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(items.map((item) => item.itemId)).toEqual(["draft"]);
|
||||
expect(items[0]?.state).toBe("draft");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import {
|
||||
anyCapability,
|
||||
type HousekeepingCapabilityContext,
|
||||
} from "../../foundation/contracts";
|
||||
import { CONTENT_INBOX_SOURCE_IDS, createContentInboxSources } from "./inbox";
|
||||
import {
|
||||
CONTENT_SEARCH_PROVIDER_IDS,
|
||||
createContentSearchProviders,
|
||||
} from "./search";
|
||||
import { CONTENT_WIDGET_IDS, createContentWidgets } from "./widgets";
|
||||
|
||||
function context(granted: readonly string[]): HousekeepingCapabilityContext {
|
||||
const permissions = new Set(granted);
|
||||
return {
|
||||
actor: { id: 42, username: "operator", rank: 7 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => permissions.has(slug),
|
||||
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
||||
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
||||
};
|
||||
}
|
||||
|
||||
describe("Content search providers", () => {
|
||||
it("uses the four exact IDs, filters item capabilities, and caps results at 25", async () => {
|
||||
const visible = anyCapability(PERMS.NEWS_VIEW);
|
||||
const hidden = anyCapability(PERMS.EVENTS_VIEW);
|
||||
const candidates = Array.from({ length: 30 }, (_, index) => ({
|
||||
id: `article-${index}`,
|
||||
title: `Article ${index}`,
|
||||
href: `/ase-next/content/editorial/articles/${index + 1}`,
|
||||
capability: index === 0 ? hidden : visible,
|
||||
}));
|
||||
const providerAdapters = {
|
||||
articles: vi.fn(async () => candidates),
|
||||
events: vi.fn(async () => []),
|
||||
media: vi.fn(async () => []),
|
||||
help: vi.fn(async () => []),
|
||||
};
|
||||
const providers = createContentSearchProviders(providerAdapters);
|
||||
|
||||
expect(CONTENT_SEARCH_PROVIDER_IDS).toEqual([
|
||||
"content.articles",
|
||||
"content.events",
|
||||
"content.media",
|
||||
"content.help",
|
||||
]);
|
||||
expect(providers.map((provider) => provider.id)).toEqual(
|
||||
CONTENT_SEARCH_PROVIDER_IDS,
|
||||
);
|
||||
const result = await providers[0].search(context([PERMS.NEWS_VIEW]), {
|
||||
term: " launch ",
|
||||
limit: 999,
|
||||
});
|
||||
expect(providerAdapters.articles).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"launch",
|
||||
25,
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.data).toHaveLength(25);
|
||||
expect(result.data.some((item) => item.id === "article-0")).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"/ase-next/content/%2e%2e/system",
|
||||
"/ase-next/content/%252e%252e/system",
|
||||
"/ase-next/content/%252f..%252fsystem",
|
||||
"/ase-next/content/%255c..%255csystem",
|
||||
"https://example.test/ase-next/content/editorial",
|
||||
"//example.test/ase-next/content/editorial",
|
||||
])(
|
||||
"rejects normalized and double-encoded traversal href %s",
|
||||
async (href) => {
|
||||
const adapters = {
|
||||
articles: async () => [
|
||||
{
|
||||
id: "unsafe",
|
||||
title: "Unsafe",
|
||||
href,
|
||||
capability: anyCapability(PERMS.NEWS_VIEW),
|
||||
},
|
||||
],
|
||||
events: async () => [],
|
||||
media: async () => [],
|
||||
help: async () => [],
|
||||
};
|
||||
const [provider] = createContentSearchProviders(adapters);
|
||||
const result = await provider.search(context([PERMS.NEWS_VIEW]), {
|
||||
term: "",
|
||||
limit: 25,
|
||||
});
|
||||
expect(result).toMatchObject({ ok: true, data: [] });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("Content inbox and widgets", () => {
|
||||
it("provides capability-selective publication and attention sources", async () => {
|
||||
const publication = vi.fn(async () => []);
|
||||
const attention = vi.fn(async () => []);
|
||||
const sources = createContentInboxSources({ publication, attention });
|
||||
|
||||
expect(CONTENT_INBOX_SOURCE_IDS).toEqual([
|
||||
"content.publication",
|
||||
"content.attention",
|
||||
]);
|
||||
const controller = new AbortController();
|
||||
const news = context([PERMS.NEWS_VIEW]);
|
||||
await sources[0].getItems(news, controller.signal);
|
||||
const forbidden = await sources[1].getItems(news, controller.signal);
|
||||
expect(publication).toHaveBeenCalledTimes(1);
|
||||
expect(attention).not.toHaveBeenCalled();
|
||||
expect(forbidden).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "FORBIDDEN" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not advertise media permissions for an event-and-poll attention source", async () => {
|
||||
const attention = vi.fn(async () => []);
|
||||
const [, source] = createContentInboxSources({
|
||||
publication: async () => [],
|
||||
attention,
|
||||
});
|
||||
const result = await source.getItems(
|
||||
context([PERMS.PAGES_VIEW, PERMS.BANNERS_VIEW]),
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(result).toMatchObject({ ok: false, error: { code: "FORBIDDEN" } });
|
||||
expect(attention).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps editorial mandatory and media/localization optional without preview DB imports", async () => {
|
||||
const adapters = {
|
||||
editorial: vi.fn(async () => ({ drafts: 2, scheduled: 1 })),
|
||||
media: vi.fn(async () => ({ items: 4 })),
|
||||
localization: vi.fn(async () => ({ stores: 3, pending: 0 })),
|
||||
};
|
||||
const widgets = createContentWidgets(adapters);
|
||||
|
||||
expect(CONTENT_WIDGET_IDS).toEqual([
|
||||
"content.editorial-summary",
|
||||
"content.media-summary",
|
||||
"content.localization-summary",
|
||||
]);
|
||||
expect(widgets.map((widget) => widget.kind)).toEqual([
|
||||
"mandatory",
|
||||
"optional",
|
||||
"optional",
|
||||
]);
|
||||
const result = await widgets[0].load(
|
||||
context([PERMS.NEWS_VIEW]),
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
data: { drafts: 2, scheduled: 1 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import "server-only";
|
||||
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import {
|
||||
anyCapability,
|
||||
type HousekeepingCapabilityContext,
|
||||
type HousekeepingWorkItem,
|
||||
} from "../../foundation/contracts";
|
||||
import { contentQuery } from "./queries/content-queries";
|
||||
|
||||
type ContentInboxKind = "publication" | "attention";
|
||||
|
||||
const ACTIONABLE_STATUS = {
|
||||
publication: new Set(["draft", "scheduled", "pending", "failed"]),
|
||||
attention: new Set(["draft", "cancelled", "closed", "failed"]),
|
||||
} as const;
|
||||
|
||||
function time(value: string | null | undefined) {
|
||||
if (!value) return null;
|
||||
const timestamp = Date.parse(value);
|
||||
if (!Number.isFinite(timestamp)) return null;
|
||||
const ageMs = Math.max(0, Date.now() - timestamp);
|
||||
return {
|
||||
occurredAt: new Date(timestamp).toISOString(),
|
||||
ageMs,
|
||||
freshness: ageMs > 86_400_000 ? ("stale" as const) : ("fresh" as const),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadContentInboxItems(
|
||||
kind: ContentInboxKind,
|
||||
context: HousekeepingCapabilityContext,
|
||||
signal: AbortSignal,
|
||||
): Promise<readonly HousekeepingWorkItem[]> {
|
||||
if (signal.aborted) throw new Error("aborted Content inbox");
|
||||
const definitions =
|
||||
kind === "publication"
|
||||
? ([
|
||||
[
|
||||
"content.editorial.articles",
|
||||
PERMS.NEWS_VIEW,
|
||||
"content.publication",
|
||||
],
|
||||
] as const)
|
||||
: ([
|
||||
["content.engagement.events", PERMS.EVENTS_VIEW, "content.attention"],
|
||||
["content.engagement.polls", PERMS.POLLS_VIEW, "content.attention"],
|
||||
] as const);
|
||||
const items: HousekeepingWorkItem[] = [];
|
||||
for (const [routeId, permission, sourceId] of definitions) {
|
||||
if (!context.has(permission)) continue;
|
||||
const result = await contentQuery.run(context, {
|
||||
routeId,
|
||||
list: { pageSize: 25, offset: 0 },
|
||||
});
|
||||
if (!result.ok) throw new Error("Content inbox query unavailable");
|
||||
for (const item of result.data.items) {
|
||||
const status = item.status?.toLocaleLowerCase() ?? "";
|
||||
if (!ACTIONABLE_STATUS[kind].has(status)) continue;
|
||||
const date = time(item.updatedAt);
|
||||
if (!date || !item.href) continue;
|
||||
items.push({
|
||||
sourceId,
|
||||
itemId: item.id,
|
||||
deduplicationKey: `${sourceId}:${routeId}:${item.id}`,
|
||||
domain: "content",
|
||||
capability: anyCapability(permission),
|
||||
severity:
|
||||
status === "failed" || status === "cancelled" ? "warning" : "info",
|
||||
priority: status === "failed" ? "high" : "normal",
|
||||
...date,
|
||||
state: status,
|
||||
titleKey: "pages.housekeeping.items.content",
|
||||
context: { title: item.title },
|
||||
href: item.href as `/ase-next/${string}`,
|
||||
actions: [],
|
||||
});
|
||||
if (items.length >= 25) return items;
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { authorizeHousekeeping } from "../../foundation/authorization";
|
||||
import { satisfiesCapability } from "../../foundation/capability-context";
|
||||
import {
|
||||
anyCapability,
|
||||
type CapabilityRequirement,
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
type HousekeepingInboxSource,
|
||||
type HousekeepingWorkItem,
|
||||
ok,
|
||||
} from "../../foundation/contracts";
|
||||
import { isSafeHousekeepingHref } from "../../foundation/housekeeping-href";
|
||||
|
||||
export const CONTENT_INBOX_SOURCE_IDS = [
|
||||
"content.publication",
|
||||
"content.attention",
|
||||
] as const;
|
||||
|
||||
type ContentInboxLoader = (
|
||||
context: HousekeepingCapabilityContext,
|
||||
signal: AbortSignal,
|
||||
) => Promise<readonly HousekeepingWorkItem[]>;
|
||||
|
||||
export interface ContentInboxAdapters {
|
||||
readonly publication: ContentInboxLoader;
|
||||
readonly attention: ContentInboxLoader;
|
||||
}
|
||||
|
||||
function createSource(
|
||||
id: (typeof CONTENT_INBOX_SOURCE_IDS)[number],
|
||||
capability: CapabilityRequirement,
|
||||
load: ContentInboxLoader,
|
||||
): HousekeepingInboxSource {
|
||||
return {
|
||||
id,
|
||||
owner: "content",
|
||||
capability,
|
||||
async getItems(context, signal) {
|
||||
const authorization = authorizeHousekeeping(context, capability);
|
||||
if (!authorization.ok) return authorization;
|
||||
try {
|
||||
const items = await load(context, signal);
|
||||
return ok(
|
||||
{
|
||||
availability: "available" as const,
|
||||
items: items
|
||||
.filter(
|
||||
(item) =>
|
||||
isSafeHousekeepingHref(item.href) &&
|
||||
satisfiesCapability(context, item.capability),
|
||||
)
|
||||
.slice(0, 25),
|
||||
},
|
||||
authorization.correlationId,
|
||||
);
|
||||
} catch {
|
||||
return fail(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
authorization.correlationId,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createContentInboxSources(
|
||||
adapters: ContentInboxAdapters,
|
||||
): readonly HousekeepingInboxSource[] {
|
||||
return [
|
||||
createSource(
|
||||
"content.publication",
|
||||
anyCapability(PERMS.NEWS_VIEW),
|
||||
adapters.publication,
|
||||
),
|
||||
createSource(
|
||||
"content.attention",
|
||||
anyCapability(PERMS.EVENTS_VIEW, PERMS.POLLS_VIEW),
|
||||
adapters.attention,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export const CONTENT_INBOX_SOURCES = createContentInboxSources({
|
||||
async publication(context, signal) {
|
||||
const { loadContentInboxItems } = await import("./inbox-production");
|
||||
return loadContentInboxItems("publication", context, signal);
|
||||
},
|
||||
async attention(context, signal) {
|
||||
const { loadContentInboxItems } = await import("./inbox-production");
|
||||
return loadContentInboxItems("attention", context, signal);
|
||||
},
|
||||
});
|
||||
@@ -3,13 +3,17 @@ import {
|
||||
anyCapability,
|
||||
type HousekeepingDomainManifest,
|
||||
} from "../../foundation/contracts";
|
||||
import { CONTENT_INBOX_SOURCES } from "./inbox";
|
||||
import { CONTENT_ROUTES } from "./routes";
|
||||
import { CONTENT_SEARCH_PROVIDERS } from "./search";
|
||||
import { CONTENT_WIDGETS } from "./widgets";
|
||||
|
||||
export const contentManifest = {
|
||||
id: "content",
|
||||
labelKey: "pages.housekeeping.domains.content.title",
|
||||
descriptionKey: "pages.housekeeping.domains.content.description",
|
||||
iconId: "file-text",
|
||||
previewHref: "/ase-next/content",
|
||||
canonicalHref: "/ase-next/content",
|
||||
capability: anyCapability(
|
||||
PERMS.NEWS_VIEW,
|
||||
PERMS.PAGES_VIEW,
|
||||
@@ -26,9 +30,8 @@ export const contentManifest = {
|
||||
PERMS.SETTINGS_VIEW,
|
||||
PERMS.SETTINGS_EDIT,
|
||||
),
|
||||
landingRouteId: null,
|
||||
routes: [],
|
||||
searchProviders: [],
|
||||
inboxSources: [],
|
||||
widgets: [],
|
||||
routes: CONTENT_ROUTES,
|
||||
searchProviders: CONTENT_SEARCH_PROVIDERS,
|
||||
inboxSources: CONTENT_INBOX_SOURCES,
|
||||
widgets: CONTENT_WIDGETS,
|
||||
} satisfies HousekeepingDomainManifest;
|
||||
@@ -0,0 +1,143 @@
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||
import { contentQuery } from "../queries/content-queries";
|
||||
import { ContentCommandForm } from "./content-command-form";
|
||||
import {
|
||||
ContentPageFrame,
|
||||
type ContentPageProps,
|
||||
parseContentListInput,
|
||||
} from "./content-page-frame";
|
||||
|
||||
export function ContentBrandPage({
|
||||
context,
|
||||
result,
|
||||
routeId,
|
||||
}: ContentPageProps) {
|
||||
const forms = context.has(PERMS.SETTINGS_EDIT) ? (
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{routeId === "content.brand.theme" ? (
|
||||
<>
|
||||
<ContentCommandForm
|
||||
commandId="content.brand.theme.update"
|
||||
buttonLabel="Save theme values"
|
||||
input={{}}
|
||||
fields={[
|
||||
{
|
||||
name: "values",
|
||||
label: "Theme values JSON",
|
||||
type: "json",
|
||||
required: true,
|
||||
maxLength: 20_000,
|
||||
},
|
||||
]}
|
||||
requiresReason
|
||||
/>
|
||||
<ContentCommandForm
|
||||
commandId="content.brand.theme.apply-preset"
|
||||
buttonLabel="Apply preset"
|
||||
input={{}}
|
||||
fields={[
|
||||
{
|
||||
name: "preset",
|
||||
label: "Preset",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 100,
|
||||
},
|
||||
]}
|
||||
requiresReason
|
||||
/>
|
||||
<ContentCommandForm
|
||||
commandId="content.brand.theme.custom-change"
|
||||
buttonLabel="Save custom theme"
|
||||
input={{ action: "update" }}
|
||||
fields={[
|
||||
{ name: "id", label: "Theme ID", type: "identifier" },
|
||||
{
|
||||
name: "name",
|
||||
label: "Name",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 100,
|
||||
},
|
||||
{
|
||||
name: "values",
|
||||
label: "Theme values JSON",
|
||||
type: "json",
|
||||
required: true,
|
||||
maxLength: 20_000,
|
||||
},
|
||||
]}
|
||||
requiresReason
|
||||
/>
|
||||
<ContentCommandForm
|
||||
commandId="content.brand.theme.apply-custom"
|
||||
buttonLabel="Apply custom theme"
|
||||
input={{}}
|
||||
fields={[
|
||||
{
|
||||
name: "id",
|
||||
label: "Theme ID",
|
||||
type: "identifier",
|
||||
required: true,
|
||||
},
|
||||
]}
|
||||
requiresReason
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{routeId === "content.brand.favicon" ? (
|
||||
<>
|
||||
<ContentCommandForm
|
||||
commandId="content.brand.favicon.save"
|
||||
buttonLabel="Save favicon"
|
||||
input={{}}
|
||||
fields={[
|
||||
{ name: "file", label: "Favicon", type: "file", required: true },
|
||||
]}
|
||||
requiresReason
|
||||
/>
|
||||
<ContentCommandForm
|
||||
commandId="content.brand.favicon.delete"
|
||||
buttonLabel="Delete favicon"
|
||||
input={{}}
|
||||
requiresReason
|
||||
/>
|
||||
<ContentCommandForm
|
||||
commandId="content.brand.logo.save"
|
||||
buttonLabel="Save logo"
|
||||
input={{}}
|
||||
fields={[
|
||||
{ name: "file", label: "Logo", type: "file", required: true },
|
||||
]}
|
||||
requiresReason
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null;
|
||||
return (
|
||||
<ContentPageFrame
|
||||
title="Brand"
|
||||
description="Manage theme, favicon, and logo assets."
|
||||
result={result}
|
||||
forms={forms}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export async function renderContentBrandPage(input: HousekeepingPageInput) {
|
||||
const routeId = input.match.routeId as ContentPageProps["routeId"];
|
||||
const result = await contentQuery.run(input.context, {
|
||||
routeId: routeId ?? "content.brand.theme",
|
||||
params: input.match.params,
|
||||
list: parseContentListInput(input.searchParams ?? {}),
|
||||
});
|
||||
return (
|
||||
<ContentBrandPage
|
||||
context={input.context}
|
||||
result={result}
|
||||
routeId={routeId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import type { HousekeepingResult } from "../../../foundation/contracts";
|
||||
|
||||
export interface ContentCommandField {
|
||||
readonly name: string;
|
||||
readonly label: string;
|
||||
readonly type:
|
||||
| "identifier"
|
||||
| "json"
|
||||
| "text"
|
||||
| "textarea"
|
||||
| "number"
|
||||
| "checkbox"
|
||||
| "select"
|
||||
| "file";
|
||||
readonly required?: boolean;
|
||||
readonly min?: number;
|
||||
readonly max?: number;
|
||||
readonly maxLength?: number;
|
||||
readonly defaultValue?: string | number | boolean;
|
||||
readonly allowUnchanged?: boolean;
|
||||
readonly options?: readonly Readonly<{
|
||||
value: string | number;
|
||||
label: string;
|
||||
}>[];
|
||||
}
|
||||
|
||||
export interface ContentCommandSubmission {
|
||||
readonly commandId: string;
|
||||
readonly input: Readonly<Record<string, unknown>>;
|
||||
readonly fields?: readonly ContentCommandField[];
|
||||
readonly requiresReason?: boolean;
|
||||
}
|
||||
|
||||
interface ContentCommandFormProps extends ContentCommandSubmission {
|
||||
readonly buttonLabel: string;
|
||||
}
|
||||
|
||||
const OMIT_FIELD = Symbol("omit optional Content command field");
|
||||
|
||||
function parseField(field: ContentCommandField, formData: FormData): unknown {
|
||||
const rawValue = formData.get(field.name);
|
||||
if (field.type === "checkbox") {
|
||||
if (field.allowUnchanged) {
|
||||
if (rawValue === null || rawValue === "") return OMIT_FIELD;
|
||||
if (rawValue === "true") return true;
|
||||
if (rawValue === "false") return false;
|
||||
return OMIT_FIELD;
|
||||
}
|
||||
return rawValue === "on";
|
||||
}
|
||||
if (rawValue === null && !field.required) return OMIT_FIELD;
|
||||
if (field.type === "file") return rawValue instanceof File ? rawValue : null;
|
||||
const raw = String(rawValue ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!raw && !field.required) return OMIT_FIELD;
|
||||
if (field.type === "number") {
|
||||
if (!raw) return raw;
|
||||
const value = Number(raw);
|
||||
if (!Number.isSafeInteger(value)) return 0;
|
||||
return Math.min(field.max ?? value, Math.max(field.min ?? value, value));
|
||||
}
|
||||
if (field.type === "select") {
|
||||
const selected = field.options?.find(
|
||||
(option) => String(option.value) === raw,
|
||||
)?.value;
|
||||
return selected ?? raw.slice(0, 500);
|
||||
}
|
||||
if (field.type === "json") {
|
||||
try {
|
||||
return JSON.parse(raw.slice(0, field.maxLength ?? 20_000));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return raw.slice(
|
||||
0,
|
||||
field.maxLength ?? (field.type === "textarea" ? 20_000 : 500),
|
||||
);
|
||||
}
|
||||
|
||||
const initialState: HousekeepingResult<unknown> | null = null;
|
||||
|
||||
export async function submitContentCommandForm(
|
||||
configuration: ContentCommandSubmission,
|
||||
_previous: HousekeepingResult<unknown> | null,
|
||||
formData: FormData,
|
||||
): Promise<HousekeepingResult<unknown>> {
|
||||
const submittedFields = (configuration.fields ?? []).flatMap((field) => {
|
||||
const value = parseField(field, formData);
|
||||
return value === OMIT_FIELD ? [] : [[field.name, value] as const];
|
||||
});
|
||||
const input = {
|
||||
...configuration.input,
|
||||
...Object.fromEntries(submittedFields),
|
||||
};
|
||||
const reason = String(formData.get("reason") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 1000);
|
||||
const { executeHousekeepingCommand } = await import(
|
||||
"@/actions/housekeeping-command"
|
||||
);
|
||||
return executeHousekeepingCommand({
|
||||
commandId: configuration.commandId,
|
||||
input,
|
||||
...(configuration.requiresReason ? { reason } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function ContentCommandForm({
|
||||
commandId,
|
||||
buttonLabel,
|
||||
input,
|
||||
fields = [],
|
||||
requiresReason = false,
|
||||
}: ContentCommandFormProps) {
|
||||
const [result, submit, pending] = useActionState(
|
||||
submitContentCommandForm.bind(null, {
|
||||
commandId,
|
||||
input,
|
||||
fields,
|
||||
requiresReason,
|
||||
}),
|
||||
initialState,
|
||||
);
|
||||
return (
|
||||
<form
|
||||
action={submit}
|
||||
data-housekeeping-command={commandId}
|
||||
className="space-y-3 rounded border border-[var(--admin-border)] p-3"
|
||||
>
|
||||
{fields.map((field) => (
|
||||
<label
|
||||
key={field.name}
|
||||
htmlFor={`${commandId}-${field.name}`}
|
||||
className="block text-sm"
|
||||
>
|
||||
{field.type === "checkbox" && field.allowUnchanged ? (
|
||||
<>
|
||||
{field.label}
|
||||
<select
|
||||
id={`${commandId}-${field.name}`}
|
||||
name={field.name}
|
||||
defaultValue=""
|
||||
className="mt-1 block w-full"
|
||||
>
|
||||
<option value="">No change</option>
|
||||
<option value="true">Enabled</option>
|
||||
<option value="false">Disabled</option>
|
||||
</select>
|
||||
</>
|
||||
) : field.type === "checkbox" ? (
|
||||
<>
|
||||
<input
|
||||
id={`${commandId}-${field.name}`}
|
||||
name={field.name}
|
||||
type="checkbox"
|
||||
defaultChecked={field.defaultValue === true}
|
||||
/>{" "}
|
||||
{field.label}
|
||||
</>
|
||||
) : field.type === "select" ? (
|
||||
<>
|
||||
{field.label}
|
||||
<select
|
||||
id={`${commandId}-${field.name}`}
|
||||
name={field.name}
|
||||
defaultValue={
|
||||
typeof field.defaultValue === "boolean"
|
||||
? undefined
|
||||
: field.defaultValue
|
||||
}
|
||||
required={field.required}
|
||||
className="mt-1 block w-full"
|
||||
>
|
||||
{field.required ? null : <option value="">No change</option>}
|
||||
{field.options?.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
) : field.type === "textarea" || field.type === "json" ? (
|
||||
<>
|
||||
{field.label}
|
||||
<textarea
|
||||
id={`${commandId}-${field.name}`}
|
||||
name={field.name}
|
||||
required={field.required}
|
||||
maxLength={field.maxLength}
|
||||
className="mt-1 block w-full"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{field.label}
|
||||
<input
|
||||
id={`${commandId}-${field.name}`}
|
||||
name={field.name}
|
||||
type={
|
||||
field.type === "number"
|
||||
? "number"
|
||||
: field.type === "file"
|
||||
? "file"
|
||||
: "text"
|
||||
}
|
||||
defaultValue={
|
||||
field.type === "file" ||
|
||||
typeof field.defaultValue === "boolean"
|
||||
? undefined
|
||||
: field.defaultValue
|
||||
}
|
||||
required={field.required}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
maxLength={field.maxLength}
|
||||
className="mt-1 block w-full"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
{requiresReason ? (
|
||||
<label htmlFor={`${commandId}-reason`} className="block text-sm">
|
||||
Reason
|
||||
<textarea
|
||||
id={`${commandId}-reason`}
|
||||
name="reason"
|
||||
required
|
||||
maxLength={1000}
|
||||
className="mt-1 block w-full"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
<button type="submit" disabled={pending}>
|
||||
{pending ? "Working..." : buttonLabel}
|
||||
</button>
|
||||
{result ? (
|
||||
<p role="status" className="text-xs text-[var(--admin-text-muted)]">
|
||||
{result.ok ? "Completed" : "Failed"} ({result.correlationId})
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { HousekeepingResult } from "../../../foundation/contracts";
|
||||
import type { ContentQueryData } from "../queries/content-queries";
|
||||
|
||||
export interface ContentPageProps {
|
||||
readonly context: import("../../../foundation/contracts").HousekeepingCapabilityContext;
|
||||
readonly result?: HousekeepingResult<ContentQueryData>;
|
||||
readonly routeId: import("../routes").ContentRouteId | null;
|
||||
}
|
||||
|
||||
export function ContentPageFrame({
|
||||
title,
|
||||
description,
|
||||
result,
|
||||
forms,
|
||||
}: {
|
||||
readonly title: string;
|
||||
readonly description: string;
|
||||
readonly result?: HousekeepingResult<ContentQueryData>;
|
||||
readonly forms?: ReactNode;
|
||||
}) {
|
||||
if (!result) {
|
||||
return (
|
||||
<section data-housekeeping-state="loading">
|
||||
<h1>{title}</h1>
|
||||
<p>{description}</p>
|
||||
<p>Loading content…</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
if (!result.ok) {
|
||||
const state = result.error.code === "FORBIDDEN" ? "forbidden" : "error";
|
||||
return (
|
||||
<section data-housekeeping-state={state} role="alert">
|
||||
<h1>{title}</h1>
|
||||
<p>{result.error.messageKey}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
const state = result.data.items.length === 0 ? "empty" : "ready";
|
||||
return (
|
||||
<section data-housekeeping-state={state} className="space-y-4">
|
||||
<header>
|
||||
<h1>{title}</h1>
|
||||
<p>{description}</p>
|
||||
</header>
|
||||
{forms}
|
||||
{result.data.items.length === 0 ? (
|
||||
<p>No matching content.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-[var(--admin-border)]">
|
||||
{result.data.items.map((item) => (
|
||||
<li key={item.id} className="py-2">
|
||||
{item.href ? <a href={item.href}>{item.title}</a> : item.title}
|
||||
{item.status ? <span> — {item.status}</span> : null}
|
||||
{item.description ? <p>{item.description}</p> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function parseContentListInput(
|
||||
searchParams: Readonly<
|
||||
Record<string, string | readonly string[] | undefined>
|
||||
>,
|
||||
) {
|
||||
const first = (value: string | readonly string[] | undefined) =>
|
||||
Array.isArray(value) ? value[0] : value;
|
||||
return {
|
||||
search: first(searchParams.search),
|
||||
pageSize: Number(first(searchParams.pageSize) ?? 25),
|
||||
offset: Number(first(searchParams.offset) ?? 0),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { executeHousekeepingCommand } from "@/actions/housekeeping-command";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import {
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
ok,
|
||||
} from "../../../foundation/contracts";
|
||||
import { ContentBrandPage } from "./brand";
|
||||
import { submitContentCommandForm } from "./content-command-form";
|
||||
import { ContentEditorialPage } from "./editorial";
|
||||
import { ContentEngagementPage } from "./engagement";
|
||||
import { ContentHelpPage } from "./help";
|
||||
import { ContentLocalizationPage } from "./localization";
|
||||
import { ContentMediaPage } from "./media";
|
||||
|
||||
vi.mock("@/actions/housekeeping-command", () => ({
|
||||
executeHousekeepingCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
function context(granted: readonly string[]): HousekeepingCapabilityContext {
|
||||
const permissions = new Set(granted);
|
||||
return {
|
||||
actor: { id: 42, username: "operator", rank: 7 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => permissions.has(slug),
|
||||
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
||||
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
||||
};
|
||||
}
|
||||
|
||||
const cases = [
|
||||
["editorial", ContentEditorialPage, PERMS.NEWS_EDIT],
|
||||
["media", ContentMediaPage, PERMS.PAGES_EDIT],
|
||||
["engagement", ContentEngagementPage, PERMS.EVENTS_EDIT],
|
||||
["help", ContentHelpPage, PERMS.PAGES_EDIT],
|
||||
["brand", ContentBrandPage, PERMS.SETTINGS_EDIT],
|
||||
["localization", ContentLocalizationPage, PERMS.SETTINGS_EDIT],
|
||||
] as const;
|
||||
|
||||
describe.each(cases)("Content %s page", (kind, Component, editPermission) => {
|
||||
it("renders loading, forbidden, empty, and real ready states", () => {
|
||||
const read = context(Object.values(PERMS));
|
||||
const render = (result?: unknown) =>
|
||||
renderToStaticMarkup(
|
||||
<Component context={read} result={result as never} routeId={null} />,
|
||||
);
|
||||
|
||||
expect(render()).toContain('data-housekeeping-state="loading"');
|
||||
expect(
|
||||
render(fail("FORBIDDEN", "errors.housekeeping.forbidden", "forbidden")),
|
||||
).toContain('data-housekeeping-state="forbidden"');
|
||||
expect(
|
||||
render(
|
||||
ok({ kind, items: [], total: 0, partialDependencies: [] }, "empty"),
|
||||
),
|
||||
).toContain('data-housekeeping-state="empty"');
|
||||
const ready = render(
|
||||
ok(
|
||||
{
|
||||
kind,
|
||||
items: [
|
||||
{
|
||||
id: "18446744073709551615",
|
||||
title: "Canonical item",
|
||||
status: "ready",
|
||||
href: `/ase-next/content/${kind}`,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
partialDependencies: [],
|
||||
},
|
||||
"ready",
|
||||
),
|
||||
);
|
||||
expect(ready).toContain('data-housekeeping-state="ready"');
|
||||
expect(ready).toContain("Canonical item");
|
||||
expect(ready).not.toContain("/admin");
|
||||
expect(read.has(editPermission)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Content actionable form wiring", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("submits canonical identifiers and a bounded reason through the real server action", async () => {
|
||||
vi.mocked(executeHousekeepingCommand).mockResolvedValue(
|
||||
ok({ before: null, after: { id: "18446744073709551615" } }, "form"),
|
||||
);
|
||||
const formData = new FormData();
|
||||
formData.set("id", "18446744073709551615");
|
||||
formData.set("title", " Updated title ");
|
||||
formData.set("reason", ` ${"r".repeat(1100)} `);
|
||||
|
||||
const result = await submitContentCommandForm(
|
||||
{
|
||||
commandId: "content.editorial.article.change",
|
||||
input: { action: "update" },
|
||||
fields: [
|
||||
{ name: "id", label: "ID", type: "identifier" },
|
||||
{ name: "title", label: "Title", type: "text", maxLength: 255 },
|
||||
],
|
||||
requiresReason: true,
|
||||
},
|
||||
null,
|
||||
formData,
|
||||
);
|
||||
|
||||
expect(executeHousekeepingCommand).toHaveBeenCalledWith({
|
||||
commandId: "content.editorial.article.change",
|
||||
input: {
|
||||
action: "update",
|
||||
id: "18446744073709551615",
|
||||
title: "Updated title",
|
||||
},
|
||||
reason: "r".repeat(1000),
|
||||
});
|
||||
expect(result).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it("parses structured JSON fields before dispatching real brand and localization forms", async () => {
|
||||
vi.mocked(executeHousekeepingCommand).mockResolvedValue(
|
||||
ok({ before: null, after: { keyCount: 1 } }, "json-form"),
|
||||
);
|
||||
const formData = new FormData();
|
||||
formData.set("data", '{ "welcome": "Hello" }');
|
||||
|
||||
await submitContentCommandForm(
|
||||
{
|
||||
commandId: "content.localization.cms.save",
|
||||
input: { locale: "en" },
|
||||
fields: [
|
||||
{
|
||||
name: "data",
|
||||
label: "Translations",
|
||||
type: "json",
|
||||
maxLength: 500_000,
|
||||
},
|
||||
],
|
||||
requiresReason: true,
|
||||
},
|
||||
null,
|
||||
formData,
|
||||
);
|
||||
|
||||
expect(executeHousekeepingCommand).toHaveBeenCalledWith({
|
||||
commandId: "content.localization.cms.save",
|
||||
input: { locale: "en", data: { welcome: "Hello" } },
|
||||
reason: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits untouched optional text and tri-state checkbox fields during updates", async () => {
|
||||
vi.mocked(executeHousekeepingCommand).mockResolvedValue(
|
||||
ok({ before: null, after: { id: "7" } }, "partial-form"),
|
||||
);
|
||||
const formData = new FormData();
|
||||
formData.set("id", "7");
|
||||
formData.set("title", "Renamed");
|
||||
formData.set("image", "");
|
||||
|
||||
await submitContentCommandForm(
|
||||
{
|
||||
commandId: "content.media.banner.change",
|
||||
input: { action: "update" },
|
||||
fields: [
|
||||
{ name: "id", label: "ID", type: "identifier", required: true },
|
||||
{ name: "title", label: "Title", type: "text" },
|
||||
{ name: "image", label: "Image", type: "text" },
|
||||
{
|
||||
name: "isActive",
|
||||
label: "Active",
|
||||
type: "checkbox",
|
||||
allowUnchanged: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
formData,
|
||||
);
|
||||
|
||||
expect(executeHousekeepingCommand).toHaveBeenCalledWith({
|
||||
commandId: "content.media.banner.change",
|
||||
input: {
|
||||
action: "update",
|
||||
id: "7",
|
||||
title: "Renamed",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["false", false],
|
||||
["true", true],
|
||||
] as const)(
|
||||
"submits an explicit tri-state checkbox value %s as %s",
|
||||
async (submitted, expected) => {
|
||||
vi.mocked(executeHousekeepingCommand).mockResolvedValue(
|
||||
ok({ before: null, after: { id: "7" } }, "explicit-checkbox"),
|
||||
);
|
||||
const formData = new FormData();
|
||||
formData.set("id", "7");
|
||||
formData.set("isActive", submitted);
|
||||
await submitContentCommandForm(
|
||||
{
|
||||
commandId: "content.engagement.event-type.change",
|
||||
input: { action: "update" },
|
||||
fields: [
|
||||
{ name: "id", label: "ID", type: "identifier", required: true },
|
||||
{
|
||||
name: "isActive",
|
||||
label: "Active",
|
||||
type: "checkbox",
|
||||
allowUnchanged: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
formData,
|
||||
);
|
||||
expect(executeHousekeepingCommand).toHaveBeenCalledWith({
|
||||
commandId: "content.engagement.event-type.change",
|
||||
input: { action: "update", id: "7", isActive: expected },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("renders poll creation with show-results checked by default", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ContentEngagementPage
|
||||
context={context([PERMS.POLLS_EDIT])}
|
||||
result={ok(
|
||||
{ kind: "engagement", items: [], total: 0, partialDependencies: [] },
|
||||
"poll-create-checkbox",
|
||||
)}
|
||||
routeId="content.engagement.poll-create"
|
||||
/>,
|
||||
);
|
||||
expect(html).toMatch(/name="showResults"[^>]*checked=""/u);
|
||||
const multipleChoice = html.match(
|
||||
/<input[^>]*name="multipleChoice"[^>]*>/u,
|
||||
)?.[0];
|
||||
expect(multipleChoice).toBeDefined();
|
||||
expect(multipleChoice).not.toContain("checked");
|
||||
});
|
||||
|
||||
it("renders poll updates with explicit unchanged, enabled, and disabled choices", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ContentEngagementPage
|
||||
context={context([PERMS.POLLS_EDIT])}
|
||||
result={ok(
|
||||
{ kind: "engagement", items: [], total: 0, partialDependencies: [] },
|
||||
"poll-update-checkbox",
|
||||
)}
|
||||
routeId="content.engagement.poll-detail"
|
||||
/>,
|
||||
);
|
||||
expect(html).toMatch(/<select[^>]*name="showResults"/u);
|
||||
expect(html).toContain("No change");
|
||||
expect(html).toContain('value="true"');
|
||||
expect(html).toContain('value="false"');
|
||||
});
|
||||
|
||||
it("renders mutation forms only with the exact capability", () => {
|
||||
const result = ok(
|
||||
{
|
||||
kind: "brand" as const,
|
||||
items: [{ id: "theme", title: "Theme", status: "active" }],
|
||||
total: 1,
|
||||
partialDependencies: [],
|
||||
},
|
||||
"brand",
|
||||
);
|
||||
const readOnly = renderToStaticMarkup(
|
||||
<ContentBrandPage
|
||||
context={context([PERMS.SETTINGS_VIEW])}
|
||||
result={result}
|
||||
routeId="content.brand.theme"
|
||||
/>,
|
||||
);
|
||||
const editor = renderToStaticMarkup(
|
||||
<ContentBrandPage
|
||||
context={context([PERMS.SETTINGS_VIEW, PERMS.SETTINGS_EDIT])}
|
||||
result={result}
|
||||
routeId="content.brand.theme"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(readOnly).not.toContain("content.brand.theme.update");
|
||||
expect(editor).toContain("content.brand.theme.update");
|
||||
expect(editor).toContain("<form");
|
||||
});
|
||||
|
||||
it("does not render email template bodies or localization payloads in summaries", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ContentHelpPage
|
||||
context={context([PERMS.PAGES_VIEW])}
|
||||
result={ok(
|
||||
{
|
||||
kind: "help",
|
||||
items: [
|
||||
{
|
||||
id: "1",
|
||||
title: "Welcome",
|
||||
status: "active",
|
||||
privatePayload: "secret template body",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
partialDependencies: [],
|
||||
},
|
||||
"help",
|
||||
)}
|
||||
routeId="content.help.email-templates"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Welcome");
|
||||
expect(html).not.toContain("secret template body");
|
||||
});
|
||||
|
||||
it("renders create-event fields required by the production validator", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ContentEngagementPage
|
||||
context={context([PERMS.EVENTS_EDIT])}
|
||||
result={ok(
|
||||
{ kind: "engagement", items: [], total: 0, partialDependencies: [] },
|
||||
"event-form",
|
||||
)}
|
||||
routeId="content.engagement.event-create"
|
||||
/>,
|
||||
);
|
||||
|
||||
for (const name of ["title", "description", "typeId", "startsAt"]) {
|
||||
expect(html).toContain(`name="${name}"`);
|
||||
}
|
||||
});
|
||||
|
||||
it("renders validator-shaped prize, question, and prefix inputs", () => {
|
||||
const eventHtml = renderToStaticMarkup(
|
||||
<ContentEngagementPage
|
||||
context={context([PERMS.EVENTS_EDIT])}
|
||||
result={ok(
|
||||
{ kind: "engagement", items: [], total: 0, partialDependencies: [] },
|
||||
"event-prize-form",
|
||||
)}
|
||||
routeId="content.engagement.event-detail"
|
||||
/>,
|
||||
);
|
||||
for (const name of [
|
||||
"position",
|
||||
"prizeType",
|
||||
"badgeCode",
|
||||
"credits",
|
||||
"pixels",
|
||||
"points",
|
||||
"itemId",
|
||||
"description",
|
||||
]) {
|
||||
expect(eventHtml).toContain(`name="${name}"`);
|
||||
}
|
||||
expect(eventHtml).not.toContain('name="prize"');
|
||||
|
||||
const pollHtml = renderToStaticMarkup(
|
||||
<ContentEngagementPage
|
||||
context={context([PERMS.POLLS_EDIT])}
|
||||
result={ok(
|
||||
{ kind: "engagement", items: [], total: 0, partialDependencies: [] },
|
||||
"poll-question-form",
|
||||
)}
|
||||
routeId="content.engagement.poll-detail"
|
||||
/>,
|
||||
);
|
||||
expect(pollHtml).toContain('name="options"');
|
||||
|
||||
const prefixHtml = renderToStaticMarkup(
|
||||
<ContentEngagementPage
|
||||
context={context([PERMS.PREFIXES_EDIT])}
|
||||
result={ok(
|
||||
{ kind: "engagement", items: [], total: 0, partialDependencies: [] },
|
||||
"prefix-form",
|
||||
)}
|
||||
routeId="content.engagement.prefixes"
|
||||
/>,
|
||||
);
|
||||
expect(prefixHtml).toContain('name="color"');
|
||||
});
|
||||
|
||||
it("matches emulator setting fields to the database column bounds", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ContentLocalizationPage
|
||||
context={context([PERMS.SETTINGS_EDIT])}
|
||||
result={ok(
|
||||
{
|
||||
kind: "localization",
|
||||
items: [],
|
||||
total: 0,
|
||||
partialDependencies: [],
|
||||
},
|
||||
"emulator-form",
|
||||
)}
|
||||
routeId="content.localization.emulator"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('name="key"');
|
||||
expect(html).toContain('maxLength="100"');
|
||||
expect(html).toContain('name="value"');
|
||||
expect(html).toContain('maxLength="512"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||
import { contentQuery } from "../queries/content-queries";
|
||||
import { ContentCommandForm } from "./content-command-form";
|
||||
import {
|
||||
ContentPageFrame,
|
||||
type ContentPageProps,
|
||||
parseContentListInput,
|
||||
} from "./content-page-frame";
|
||||
|
||||
export function ContentEditorialPage({
|
||||
context,
|
||||
result,
|
||||
routeId,
|
||||
}: ContentPageProps) {
|
||||
const canNews = context.has(PERMS.NEWS_EDIT);
|
||||
const canPages = context.has(PERMS.PAGES_EDIT);
|
||||
return (
|
||||
<ContentPageFrame
|
||||
title="Editorial content"
|
||||
description="Manage articles, navigation, tags, and writable boxes."
|
||||
result={result}
|
||||
forms={
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{canNews && routeId?.includes("article") ? (
|
||||
<ContentCommandForm
|
||||
commandId="content.editorial.article.change"
|
||||
buttonLabel="Save article"
|
||||
input={{
|
||||
action: routeId.endsWith("create") ? "create" : "update",
|
||||
}}
|
||||
fields={[
|
||||
{ name: "id", label: "Article ID", type: "identifier" },
|
||||
{
|
||||
name: "title",
|
||||
label: "Title",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
name: "content",
|
||||
label: "Body",
|
||||
type: "textarea",
|
||||
required: true,
|
||||
maxLength: 100_000,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
{canPages && routeId === "content.editorial.tags" ? (
|
||||
<ContentCommandForm
|
||||
commandId="content.editorial.tag.change"
|
||||
buttonLabel="Save tag"
|
||||
input={{ action: "update" }}
|
||||
fields={[
|
||||
{ name: "id", label: "Tag ID", type: "identifier" },
|
||||
{
|
||||
name: "name",
|
||||
label: "Name",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 100,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
{canPages && routeId === "content.editorial.writeable-boxes" ? (
|
||||
<ContentCommandForm
|
||||
commandId="content.editorial.writeable-box.change"
|
||||
buttonLabel="Save box"
|
||||
input={{ action: "update" }}
|
||||
fields={[
|
||||
{ name: "id", label: "Box ID", type: "identifier" },
|
||||
{
|
||||
name: "title",
|
||||
label: "Title",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
name: "content",
|
||||
label: "Content",
|
||||
type: "textarea",
|
||||
maxLength: 20_000,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export async function renderContentEditorialPage(input: HousekeepingPageInput) {
|
||||
const routeId = input.match.routeId as ContentPageProps["routeId"];
|
||||
const result = await contentQuery.run(input.context, {
|
||||
routeId: routeId ?? "content.editorial.articles",
|
||||
params: input.match.params,
|
||||
list: parseContentListInput(input.searchParams ?? {}),
|
||||
});
|
||||
return (
|
||||
<ContentEditorialPage
|
||||
context={input.context}
|
||||
result={result}
|
||||
routeId={routeId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||
import { contentQuery } from "../queries/content-queries";
|
||||
import {
|
||||
type ContentCommandField,
|
||||
ContentCommandForm,
|
||||
} from "./content-command-form";
|
||||
import {
|
||||
ContentPageFrame,
|
||||
type ContentPageProps,
|
||||
parseContentListInput,
|
||||
} from "./content-page-frame";
|
||||
|
||||
export function ContentEngagementPage({
|
||||
context,
|
||||
result,
|
||||
routeId,
|
||||
}: ContentPageProps) {
|
||||
const canEvents = context.has(PERMS.EVENTS_EDIT);
|
||||
const canPolls = context.has(PERMS.POLLS_EDIT);
|
||||
const canPrefixes = context.has(PERMS.PREFIXES_EDIT);
|
||||
const creatingEvent = routeId === "content.engagement.event-create";
|
||||
const creatingPoll = routeId === "content.engagement.poll-create";
|
||||
const eventFields: readonly ContentCommandField[] = [
|
||||
{
|
||||
name: "id",
|
||||
label: "Event ID",
|
||||
type: "identifier",
|
||||
required: !creatingEvent,
|
||||
},
|
||||
{
|
||||
name: "title",
|
||||
label: "Title",
|
||||
type: "text",
|
||||
required: creatingEvent,
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
label: "Description",
|
||||
type: "textarea",
|
||||
required: creatingEvent,
|
||||
maxLength: 20_000,
|
||||
},
|
||||
{
|
||||
name: "typeId",
|
||||
label: "Event type ID",
|
||||
type: "identifier",
|
||||
required: creatingEvent,
|
||||
},
|
||||
{ name: "roomId", label: "Room ID", type: "identifier" },
|
||||
{
|
||||
name: "startsAt",
|
||||
label: "Starts at",
|
||||
type: "text",
|
||||
required: creatingEvent,
|
||||
maxLength: 50,
|
||||
},
|
||||
{ name: "endsAt", label: "Ends at", type: "text", maxLength: 50 },
|
||||
{ name: "maxPlayers", label: "Maximum players", type: "number", min: 1 },
|
||||
{
|
||||
name: "isRecurring",
|
||||
label: "Recurring",
|
||||
type: "checkbox",
|
||||
allowUnchanged: !creatingEvent,
|
||||
},
|
||||
{
|
||||
name: "recurrenceRule",
|
||||
label: "Recurrence rule",
|
||||
type: "text",
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "draft", label: "Draft" },
|
||||
{ value: "published", label: "Published" },
|
||||
{ value: "cancelled", label: "Cancelled" },
|
||||
{ value: "completed", label: "Completed" },
|
||||
],
|
||||
},
|
||||
{ name: "image", label: "Image URL", type: "text", maxLength: 500 },
|
||||
];
|
||||
const pollFields: readonly ContentCommandField[] = [
|
||||
{
|
||||
name: "id",
|
||||
label: "Poll ID",
|
||||
type: "identifier",
|
||||
required: !creatingPoll,
|
||||
},
|
||||
{
|
||||
name: "title",
|
||||
label: "Title",
|
||||
type: "text",
|
||||
required: creatingPoll,
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
label: "Description",
|
||||
type: "textarea",
|
||||
maxLength: 2_000,
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "draft", label: "Draft" },
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "closed", label: "Closed" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "showResults",
|
||||
label: "Show results",
|
||||
type: "checkbox",
|
||||
defaultValue: creatingPoll,
|
||||
allowUnchanged: !creatingPoll,
|
||||
},
|
||||
{
|
||||
name: "multipleChoice",
|
||||
label: "Allow multiple choices",
|
||||
type: "checkbox",
|
||||
allowUnchanged: !creatingPoll,
|
||||
},
|
||||
{ name: "startsAt", label: "Starts at", type: "text", maxLength: 50 },
|
||||
{ name: "endsAt", label: "Ends at", type: "text", maxLength: 50 },
|
||||
];
|
||||
return (
|
||||
<ContentPageFrame
|
||||
title="Engagement"
|
||||
description="Manage events, polls, and community prefixes."
|
||||
result={result}
|
||||
forms={
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{canEvents && routeId === "content.engagement.event-types" ? (
|
||||
<ContentCommandForm
|
||||
commandId="content.engagement.event-type.change"
|
||||
buttonLabel="Save event type"
|
||||
input={{ action: "update" }}
|
||||
fields={[
|
||||
{
|
||||
name: "id",
|
||||
label: "Type ID",
|
||||
type: "identifier",
|
||||
required: true,
|
||||
},
|
||||
{ name: "name", label: "Name", type: "text", maxLength: 100 },
|
||||
{ name: "slug", label: "Slug", type: "text", maxLength: 100 },
|
||||
{
|
||||
name: "description",
|
||||
label: "Description",
|
||||
type: "textarea",
|
||||
maxLength: 500,
|
||||
},
|
||||
{ name: "color", label: "Color", type: "text", maxLength: 20 },
|
||||
{ name: "icon", label: "Icon", type: "text", maxLength: 50 },
|
||||
{
|
||||
name: "isActive",
|
||||
label: "Active",
|
||||
type: "checkbox",
|
||||
allowUnchanged: true,
|
||||
},
|
||||
{
|
||||
name: "minRank",
|
||||
label: "Minimum rank",
|
||||
type: "number",
|
||||
min: 0,
|
||||
max: 7,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
{canEvents &&
|
||||
routeId?.includes("event") &&
|
||||
routeId !== "content.engagement.event-types" ? (
|
||||
<>
|
||||
<ContentCommandForm
|
||||
commandId="content.engagement.event.change"
|
||||
buttonLabel="Save event"
|
||||
input={{ action: creatingEvent ? "create" : "update" }}
|
||||
fields={eventFields}
|
||||
/>
|
||||
<ContentCommandForm
|
||||
commandId="content.engagement.event-prize.change"
|
||||
buttonLabel="Save prize"
|
||||
input={{ action: "create" }}
|
||||
fields={[
|
||||
{
|
||||
name: "eventId",
|
||||
label: "Event ID",
|
||||
type: "identifier",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "position",
|
||||
label: "Position",
|
||||
type: "number",
|
||||
min: 1,
|
||||
defaultValue: 1,
|
||||
},
|
||||
{
|
||||
name: "prizeType",
|
||||
label: "Prize type",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "badge", label: "Badge" },
|
||||
{ value: "credits", label: "Credits" },
|
||||
{ value: "pixels", label: "Pixels" },
|
||||
{ value: "points", label: "Points" },
|
||||
{ value: "item", label: "Item" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "badgeCode",
|
||||
label: "Badge code",
|
||||
type: "text",
|
||||
maxLength: 50,
|
||||
},
|
||||
{
|
||||
name: "credits",
|
||||
label: "Credits",
|
||||
type: "number",
|
||||
min: 0,
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
name: "pixels",
|
||||
label: "Pixels",
|
||||
type: "number",
|
||||
min: 0,
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
name: "points",
|
||||
label: "Points",
|
||||
type: "number",
|
||||
min: 0,
|
||||
defaultValue: 0,
|
||||
},
|
||||
{ name: "itemId", label: "Item ID", type: "identifier" },
|
||||
{
|
||||
name: "description",
|
||||
label: "Description",
|
||||
type: "text",
|
||||
maxLength: 255,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<ContentCommandForm
|
||||
commandId="content.engagement.event-winner.add"
|
||||
buttonLabel="Add winner"
|
||||
input={{}}
|
||||
fields={[
|
||||
{
|
||||
name: "eventId",
|
||||
label: "Event ID",
|
||||
type: "identifier",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "userId",
|
||||
label: "User ID",
|
||||
type: "identifier",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "position",
|
||||
label: "Position",
|
||||
type: "number",
|
||||
min: 1,
|
||||
defaultValue: 1,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{canPolls && routeId?.includes("poll") ? (
|
||||
<>
|
||||
<ContentCommandForm
|
||||
commandId="content.engagement.poll.change"
|
||||
buttonLabel="Save poll"
|
||||
input={{ action: creatingPoll ? "create" : "update" }}
|
||||
fields={pollFields}
|
||||
/>
|
||||
<ContentCommandForm
|
||||
commandId="content.engagement.poll-question.change"
|
||||
buttonLabel="Save question"
|
||||
input={{ action: "create" }}
|
||||
fields={[
|
||||
{
|
||||
name: "pollId",
|
||||
label: "Poll ID",
|
||||
type: "identifier",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "question",
|
||||
label: "Question",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 500,
|
||||
},
|
||||
{
|
||||
name: "type",
|
||||
label: "Question type",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "single", label: "Single choice" },
|
||||
{ value: "multiple", label: "Multiple choice" },
|
||||
{ value: "text", label: "Free text" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "sortOrder",
|
||||
label: "Sort order",
|
||||
type: "number",
|
||||
min: 0,
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
name: "options",
|
||||
label: "Options",
|
||||
type: "textarea",
|
||||
required: true,
|
||||
maxLength: 20_000,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{canPrefixes && routeId === "content.engagement.prefixes" ? (
|
||||
<>
|
||||
<ContentCommandForm
|
||||
commandId="content.engagement.prefix.change"
|
||||
buttonLabel="Save prefix"
|
||||
input={{ action: "update" }}
|
||||
fields={[
|
||||
{
|
||||
name: "id",
|
||||
label: "Prefix ID",
|
||||
type: "identifier",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "text",
|
||||
label: "Text",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
name: "color",
|
||||
label: "Color",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 32,
|
||||
},
|
||||
{ name: "icon", label: "Icon", type: "text", maxLength: 255 },
|
||||
{
|
||||
name: "effect",
|
||||
label: "Effect",
|
||||
type: "text",
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
name: "active",
|
||||
label: "Active",
|
||||
type: "checkbox",
|
||||
allowUnchanged: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<ContentCommandForm
|
||||
commandId="content.engagement.prefix-blacklist.change"
|
||||
buttonLabel="Update blacklist"
|
||||
input={{ action: "add" }}
|
||||
fields={[
|
||||
{
|
||||
name: "word",
|
||||
label: "Word",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 255,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<ContentCommandForm
|
||||
commandId="content.engagement.prefix-settings.update"
|
||||
buttonLabel="Save prefix settings"
|
||||
input={{}}
|
||||
fields={[
|
||||
{
|
||||
name: "settings",
|
||||
label: "Prefix settings JSON",
|
||||
type: "json",
|
||||
required: true,
|
||||
maxLength: 20_000,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export async function renderContentEngagementPage(
|
||||
input: HousekeepingPageInput,
|
||||
) {
|
||||
const routeId = input.match.routeId as ContentPageProps["routeId"];
|
||||
const result = await contentQuery.run(input.context, {
|
||||
routeId: routeId ?? "content.engagement.events",
|
||||
params: input.match.params,
|
||||
list: parseContentListInput(input.searchParams ?? {}),
|
||||
});
|
||||
return (
|
||||
<ContentEngagementPage
|
||||
context={input.context}
|
||||
result={result}
|
||||
routeId={routeId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||
import { contentQuery } from "../queries/content-queries";
|
||||
import { ContentCommandForm } from "./content-command-form";
|
||||
import {
|
||||
ContentPageFrame,
|
||||
type ContentPageProps,
|
||||
parseContentListInput,
|
||||
} from "./content-page-frame";
|
||||
|
||||
export function ContentHelpPage({
|
||||
context,
|
||||
result,
|
||||
routeId,
|
||||
}: ContentPageProps) {
|
||||
const forms = context.has(PERMS.PAGES_EDIT) ? (
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{routeId?.includes("question") ? (
|
||||
<ContentCommandForm
|
||||
commandId="content.help.question.change"
|
||||
buttonLabel="Save help question"
|
||||
input={{ action: routeId.endsWith("create") ? "create" : "update" }}
|
||||
fields={[
|
||||
{ name: "id", label: "Question ID", type: "identifier" },
|
||||
{
|
||||
name: "name",
|
||||
label: "Question",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
name: "answer",
|
||||
label: "Answer",
|
||||
type: "textarea",
|
||||
required: true,
|
||||
maxLength: 20_000,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
{routeId === "content.help.email-templates" ? (
|
||||
<ContentCommandForm
|
||||
commandId="content.help.email-template.change"
|
||||
buttonLabel="Save email template"
|
||||
input={{ action: "update" }}
|
||||
fields={[
|
||||
{
|
||||
name: "id",
|
||||
label: "Template ID",
|
||||
type: "identifier",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "subject",
|
||||
label: "Subject",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
name: "body",
|
||||
label: "Body",
|
||||
type: "textarea",
|
||||
required: true,
|
||||
maxLength: 100_000,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null;
|
||||
return (
|
||||
<ContentPageFrame
|
||||
title="Help content"
|
||||
description="Manage help questions and email templates."
|
||||
result={result}
|
||||
forms={forms}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export async function renderContentHelpPage(input: HousekeepingPageInput) {
|
||||
const routeId = input.match.routeId as ContentPageProps["routeId"];
|
||||
const result = await contentQuery.run(input.context, {
|
||||
routeId: routeId ?? "content.help.questions",
|
||||
params: input.match.params,
|
||||
list: parseContentListInput(input.searchParams ?? {}),
|
||||
});
|
||||
return (
|
||||
<ContentHelpPage
|
||||
context={input.context}
|
||||
result={result}
|
||||
routeId={routeId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||
import { contentQuery } from "../queries/content-queries";
|
||||
import { ContentCommandForm } from "./content-command-form";
|
||||
import {
|
||||
ContentPageFrame,
|
||||
type ContentPageProps,
|
||||
parseContentListInput,
|
||||
} from "./content-page-frame";
|
||||
|
||||
export function ContentLocalizationPage({
|
||||
context,
|
||||
result,
|
||||
routeId,
|
||||
}: ContentPageProps) {
|
||||
const forms = context.has(PERMS.SETTINGS_EDIT) ? (
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{routeId === "content.localization.cms" ? (
|
||||
<ContentCommandForm
|
||||
commandId="content.localization.cms.save"
|
||||
buttonLabel="Save CMS translations"
|
||||
input={{}}
|
||||
fields={[
|
||||
{
|
||||
name: "locale",
|
||||
label: "Locale",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 16,
|
||||
},
|
||||
{
|
||||
name: "data",
|
||||
label: "Translation JSON",
|
||||
type: "json",
|
||||
required: true,
|
||||
maxLength: 500_000,
|
||||
},
|
||||
]}
|
||||
requiresReason
|
||||
/>
|
||||
) : null}
|
||||
{routeId === "content.localization.client" ? (
|
||||
<ContentCommandForm
|
||||
commandId="content.localization.client.save"
|
||||
buttonLabel="Save client translations"
|
||||
input={{}}
|
||||
fields={[
|
||||
{
|
||||
name: "fileId",
|
||||
label: "Translation file",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 100,
|
||||
},
|
||||
{
|
||||
name: "data",
|
||||
label: "Translation JSON",
|
||||
type: "json",
|
||||
required: true,
|
||||
maxLength: 500_000,
|
||||
},
|
||||
]}
|
||||
requiresReason
|
||||
/>
|
||||
) : null}
|
||||
{routeId === "content.localization.emulator" ? (
|
||||
<ContentCommandForm
|
||||
commandId="content.localization.emulator.save"
|
||||
buttonLabel="Save emulator translation"
|
||||
input={{}}
|
||||
fields={[
|
||||
{
|
||||
name: "key",
|
||||
label: "Key",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 100,
|
||||
},
|
||||
{
|
||||
name: "value",
|
||||
label: "Value",
|
||||
type: "textarea",
|
||||
required: true,
|
||||
maxLength: 512,
|
||||
},
|
||||
]}
|
||||
requiresReason
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null;
|
||||
return (
|
||||
<ContentPageFrame
|
||||
title="Localization"
|
||||
description="Manage CMS, client, and emulator translation stores."
|
||||
result={result}
|
||||
forms={forms}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export async function renderContentLocalizationPage(
|
||||
input: HousekeepingPageInput,
|
||||
) {
|
||||
const routeId = input.match.routeId as ContentPageProps["routeId"];
|
||||
const result = await contentQuery.run(input.context, {
|
||||
routeId: routeId ?? "content.localization.overview",
|
||||
params: input.match.params,
|
||||
list: parseContentListInput(input.searchParams ?? {}),
|
||||
});
|
||||
return (
|
||||
<ContentLocalizationPage
|
||||
context={input.context}
|
||||
result={result}
|
||||
routeId={routeId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||
import { contentQuery } from "../queries/content-queries";
|
||||
import { ContentCommandForm } from "./content-command-form";
|
||||
import {
|
||||
ContentPageFrame,
|
||||
type ContentPageProps,
|
||||
parseContentListInput,
|
||||
} from "./content-page-frame";
|
||||
|
||||
export function ContentMediaPage({
|
||||
context,
|
||||
result,
|
||||
routeId,
|
||||
}: ContentPageProps) {
|
||||
const canPages = context.has(PERMS.PAGES_EDIT);
|
||||
const canBanners = context.has(PERMS.BANNERS_EDIT);
|
||||
return (
|
||||
<ContentPageFrame
|
||||
title="Media"
|
||||
description="Manage photos, uploaded media, banners, and advertisements."
|
||||
result={result}
|
||||
forms={
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{canPages && routeId === "content.media.photos" ? (
|
||||
<ContentCommandForm
|
||||
commandId="content.media.photo.delete"
|
||||
buttonLabel="Delete photo"
|
||||
input={{}}
|
||||
fields={[
|
||||
{
|
||||
name: "id",
|
||||
label: "Photo ID",
|
||||
type: "identifier",
|
||||
required: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
{canPages && routeId === "content.media.library" ? (
|
||||
<>
|
||||
<ContentCommandForm
|
||||
commandId="content.media.asset.upload"
|
||||
buttonLabel="Upload media"
|
||||
input={{}}
|
||||
fields={[
|
||||
{
|
||||
name: "file",
|
||||
label: "Media file",
|
||||
type: "file",
|
||||
required: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<ContentCommandForm
|
||||
commandId="content.media.asset.delete"
|
||||
buttonLabel="Delete media"
|
||||
input={{}}
|
||||
fields={[
|
||||
{
|
||||
name: "filename",
|
||||
label: "Filename",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 255,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{canBanners && routeId === "content.media.banners" ? (
|
||||
<ContentCommandForm
|
||||
commandId="content.media.banner.change"
|
||||
buttonLabel="Save banner"
|
||||
input={{ action: "update" }}
|
||||
fields={[
|
||||
{ name: "id", label: "Banner ID", type: "identifier" },
|
||||
{ name: "title", label: "Title", type: "text", maxLength: 255 },
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
{canPages && routeId?.includes("ad") ? (
|
||||
<ContentCommandForm
|
||||
commandId="content.media.ad.change"
|
||||
buttonLabel="Save advertisement"
|
||||
input={{
|
||||
action: routeId.endsWith("create") ? "create" : "update",
|
||||
}}
|
||||
fields={[
|
||||
{ name: "id", label: "Advertisement ID", type: "identifier" },
|
||||
{
|
||||
name: "image",
|
||||
label: "Image path",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 500,
|
||||
},
|
||||
{
|
||||
name: "url",
|
||||
label: "Destination",
|
||||
type: "text",
|
||||
maxLength: 1000,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export async function renderContentMediaPage(input: HousekeepingPageInput) {
|
||||
const routeId = input.match.routeId as ContentPageProps["routeId"];
|
||||
const result = await contentQuery.run(input.context, {
|
||||
routeId: routeId ?? "content.media.photos",
|
||||
params: input.match.params,
|
||||
list: parseContentListInput(input.searchParams ?? {}),
|
||||
});
|
||||
return (
|
||||
<ContentMediaPage
|
||||
context={input.context}
|
||||
result={result}
|
||||
routeId={routeId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
import "server-only";
|
||||
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { type ContentRouteId, contentRouteGroup } from "../routes";
|
||||
import type {
|
||||
ContentQueryData,
|
||||
ContentQueryItem,
|
||||
NormalizedContentQueryInput,
|
||||
} from "./content-queries";
|
||||
|
||||
type RawRow = Readonly<Record<string, unknown>>;
|
||||
|
||||
interface QueryDefinition {
|
||||
readonly statement: string;
|
||||
readonly href: string;
|
||||
readonly appendId?: boolean;
|
||||
}
|
||||
|
||||
export const CONTENT_QUERY_DEFINITIONS = {
|
||||
"content.editorial.articles": {
|
||||
statement:
|
||||
"SELECT id, title, slug AS description, 'published' AS status, updated_at FROM website_articles ORDER BY created_at DESC",
|
||||
href: "/ase-next/content/editorial/articles/",
|
||||
appendId: true,
|
||||
},
|
||||
"content.editorial.article-detail": {
|
||||
statement:
|
||||
"SELECT id, title, slug AS description, 'published' AS status, updated_at FROM website_articles ORDER BY created_at DESC",
|
||||
href: "/ase-next/content/editorial/articles/",
|
||||
appendId: true,
|
||||
},
|
||||
"content.editorial.tags": {
|
||||
statement:
|
||||
"SELECT id, name AS title, background_color AS status, updated_at FROM tags ORDER BY name",
|
||||
href: "/ase-next/content/editorial/tags",
|
||||
},
|
||||
"content.editorial.writeable-boxes": {
|
||||
statement:
|
||||
"SELECT id, title, CASE WHEN is_active = 1 THEN 'active' ELSE 'hidden' END AS status, updated_at FROM website_writeable_boxes ORDER BY position, id",
|
||||
href: "/ase-next/content/editorial/writeable-boxes",
|
||||
},
|
||||
"content.media.photos": {
|
||||
statement:
|
||||
"SELECT id, url AS title, 'published' AS status, FROM_UNIXTIME(timestamp) AS updated_at FROM camera_web ORDER BY id DESC",
|
||||
href: "/ase-next/content/media/photos",
|
||||
},
|
||||
"content.media.banners": {
|
||||
statement:
|
||||
"SELECT id, title, CASE WHEN is_active = 1 THEN 'active' ELSE 'hidden' END AS status, NULL AS updated_at FROM website_banners ORDER BY sort_order, id",
|
||||
href: "/ase-next/content/media/banners",
|
||||
},
|
||||
"content.media.ads": {
|
||||
statement:
|
||||
"SELECT id, image AS title, 'active' AS status, updated_at FROM website_ads ORDER BY created_at DESC",
|
||||
href: "/ase-next/content/media/ads/",
|
||||
appendId: true,
|
||||
},
|
||||
"content.media.ad-detail": {
|
||||
statement:
|
||||
"SELECT id, image AS title, 'active' AS status, updated_at FROM website_ads ORDER BY created_at DESC",
|
||||
href: "/ase-next/content/media/ads/",
|
||||
appendId: true,
|
||||
},
|
||||
"content.engagement.events": {
|
||||
statement:
|
||||
"SELECT id, title, status, updated_at FROM website_events ORDER BY starts_at DESC",
|
||||
href: "/ase-next/content/engagement/events/",
|
||||
appendId: true,
|
||||
},
|
||||
"content.engagement.event-detail": {
|
||||
statement:
|
||||
"SELECT id, title, status, updated_at FROM website_events ORDER BY starts_at DESC",
|
||||
href: "/ase-next/content/engagement/events/",
|
||||
appendId: true,
|
||||
},
|
||||
"content.engagement.event-types": {
|
||||
statement:
|
||||
"SELECT id, name AS title, CASE WHEN is_active = 1 THEN 'active' ELSE 'inactive' END AS status, NULL AS updated_at FROM website_event_types ORDER BY name",
|
||||
href: "/ase-next/content/engagement/events/types",
|
||||
},
|
||||
"content.engagement.polls": {
|
||||
statement:
|
||||
"SELECT id, title, status, updated_at FROM website_polls ORDER BY created_at DESC",
|
||||
href: "/ase-next/content/engagement/polls/",
|
||||
appendId: true,
|
||||
},
|
||||
"content.engagement.poll-detail": {
|
||||
statement:
|
||||
"SELECT id, title, status, updated_at FROM website_polls ORDER BY created_at DESC",
|
||||
href: "/ase-next/content/engagement/polls/",
|
||||
appendId: true,
|
||||
},
|
||||
"content.engagement.prefixes": {
|
||||
statement:
|
||||
"SELECT id, text AS title, color AS description, CASE WHEN active = 1 THEN 'active' ELSE 'inactive' END AS status, NULL AS updated_at FROM custom_prefixes ORDER BY id DESC",
|
||||
href: "/ase-next/content/engagement/prefixes",
|
||||
},
|
||||
"content.help.questions": {
|
||||
statement:
|
||||
"SELECT id, name AS title, CONCAT('position-', position) AS status, NULL AS updated_at FROM website_help_center_categories ORDER BY position, id",
|
||||
href: "/ase-next/content/help/questions/",
|
||||
appendId: true,
|
||||
},
|
||||
"content.help.question-detail": {
|
||||
statement:
|
||||
"SELECT id, name AS title, CONCAT('position-', position) AS status, NULL AS updated_at FROM website_help_center_categories ORDER BY position, id",
|
||||
href: "/ase-next/content/help/questions/",
|
||||
appendId: true,
|
||||
},
|
||||
"content.help.email-templates": {
|
||||
statement:
|
||||
"SELECT id, name AS title, subject AS description, CASE WHEN is_active = 1 THEN 'active' ELSE 'inactive' END AS status, updated_at FROM email_templates ORDER BY name",
|
||||
href: "/ase-next/content/help/email-templates",
|
||||
},
|
||||
"content.localization.emulator": {
|
||||
statement:
|
||||
"SELECT emulator_settings.key AS id, emulator_settings.key AS title, LEFT(emulator_settings.value, 120) AS description, 'configured' AS status, NULL AS updated_at FROM emulator_settings ORDER BY emulator_settings.key",
|
||||
href: "/ase-next/content/localization/emulator",
|
||||
},
|
||||
} as const satisfies Partial<Record<ContentRouteId, QueryDefinition>>;
|
||||
|
||||
const EMPTY_ROUTES = new Set<ContentRouteId>([
|
||||
"content.editorial.article-create",
|
||||
"content.media.ad-create",
|
||||
"content.engagement.event-create",
|
||||
"content.engagement.poll-create",
|
||||
"content.help.question-create",
|
||||
]);
|
||||
|
||||
function rows(result: unknown): readonly RawRow[] {
|
||||
if (!Array.isArray(result) || !Array.isArray(result[0])) {
|
||||
throw new Error("invalid Content query result");
|
||||
}
|
||||
return result[0] as readonly RawRow[];
|
||||
}
|
||||
|
||||
function value(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string"
|
||||
? value
|
||||
: value == null
|
||||
? fallback
|
||||
: String(value);
|
||||
}
|
||||
|
||||
function updatedAt(value: unknown): string | null {
|
||||
if (value == null) return null;
|
||||
const parsed = value instanceof Date ? value : new Date(String(value));
|
||||
return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : null;
|
||||
}
|
||||
|
||||
function response(
|
||||
input: NormalizedContentQueryInput,
|
||||
items: readonly ContentQueryItem[],
|
||||
total = items.length,
|
||||
): ContentQueryData {
|
||||
const start = input.list.offset;
|
||||
return {
|
||||
kind: contentRouteGroup(input.routeId),
|
||||
items: items.slice(start, start + input.list.pageSize),
|
||||
total,
|
||||
partialDependencies: [],
|
||||
};
|
||||
}
|
||||
|
||||
function matchesListSearch(
|
||||
input: NormalizedContentQueryInput,
|
||||
item: ContentQueryItem,
|
||||
): boolean {
|
||||
const needle = input.list.search.toLocaleLowerCase();
|
||||
return (
|
||||
needle.length === 0 ||
|
||||
item.title.toLocaleLowerCase().includes(needle) ||
|
||||
item.description?.toLocaleLowerCase().includes(needle) === true
|
||||
);
|
||||
}
|
||||
|
||||
function mapRows(
|
||||
definition: QueryDefinition,
|
||||
rawRows: readonly RawRow[],
|
||||
): readonly ContentQueryItem[] {
|
||||
return rawRows.map((row) => {
|
||||
const id = value(row.id);
|
||||
if (!id) throw new Error("invalid Content identifier");
|
||||
return {
|
||||
id,
|
||||
title: value(row.title, "Untitled content"),
|
||||
description: value(row.description) || undefined,
|
||||
status: value(row.status) || undefined,
|
||||
updatedAt: updatedAt(row.updated_at),
|
||||
href: definition.appendId ? definition.href + id : definition.href,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function statementParts(statement: string): {
|
||||
readonly selection: string;
|
||||
readonly ordering: string;
|
||||
} {
|
||||
const match = /^(.*?)(\s+ORDER BY\s+.+)$/iu.exec(statement);
|
||||
return match
|
||||
? { selection: match[1], ordering: match[2] }
|
||||
: { selection: statement, ordering: "" };
|
||||
}
|
||||
|
||||
function totalFromRows(rawRows: readonly RawRow[]): number {
|
||||
const total = Number(rawRows[0]?.total ?? 0);
|
||||
if (!Number.isSafeInteger(total) || total < 0)
|
||||
throw new Error("invalid Content query total");
|
||||
return total;
|
||||
}
|
||||
|
||||
async function databaseRoute(
|
||||
input: NormalizedContentQueryInput,
|
||||
definition: QueryDefinition,
|
||||
): Promise<ContentQueryData> {
|
||||
const [{ sql }, { db }] = await Promise.all([
|
||||
import("drizzle-orm"),
|
||||
import("@/lib/db"),
|
||||
]);
|
||||
const { selection, ordering } = statementParts(definition.statement);
|
||||
const filters: SQL[] = [];
|
||||
if (input.list.search) {
|
||||
const pattern = `%${input.list.search.toLocaleLowerCase()}%`;
|
||||
filters.push(
|
||||
/\bAS\s+description\b/iu.test(selection)
|
||||
? sql`(LOWER(COALESCE(title, '')) LIKE ${pattern} OR LOWER(COALESCE(description, '')) LIKE ${pattern})`
|
||||
: sql`LOWER(COALESCE(title, '')) LIKE ${pattern}`,
|
||||
);
|
||||
}
|
||||
if (input.params.id) filters.push(sql`CAST(id AS CHAR) = ${input.params.id}`);
|
||||
const filtered = filters.length
|
||||
? sql`${sql.raw(selection)} HAVING ${sql.join(filters, sql` AND `)}`
|
||||
: sql.raw(selection);
|
||||
const total = totalFromRows(
|
||||
rows(
|
||||
await db.execute(
|
||||
sql`SELECT COUNT(*) AS total FROM (${filtered}) AS content_rows`,
|
||||
),
|
||||
),
|
||||
);
|
||||
const items = mapRows(
|
||||
definition,
|
||||
rows(
|
||||
await db.execute(
|
||||
sql`${filtered} ${sql.raw(ordering)} LIMIT ${input.list.pageSize} OFFSET ${input.list.offset}`,
|
||||
),
|
||||
),
|
||||
);
|
||||
return {
|
||||
kind: contentRouteGroup(input.routeId),
|
||||
items,
|
||||
total,
|
||||
partialDependencies: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function mediaLibrary(
|
||||
input: NormalizedContentQueryInput,
|
||||
): Promise<ContentQueryData> {
|
||||
const [{ readdir, stat }, { resolve }, { MEDIA_ROOT }] = await Promise.all([
|
||||
import("node:fs/promises"),
|
||||
import("node:path"),
|
||||
import("@/lib/media-storage"),
|
||||
]);
|
||||
let names: string[];
|
||||
try {
|
||||
names = (await readdir(MEDIA_ROOT)).filter((name) =>
|
||||
/[.](png|jpe?g|gif|webp|svg|bmp)$/iu.test(name),
|
||||
);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT")
|
||||
return response(input, []);
|
||||
throw error;
|
||||
}
|
||||
const entries = await Promise.all(
|
||||
names.map(async (name) => ({
|
||||
name,
|
||||
metadata: await stat(resolve(MEDIA_ROOT, name)),
|
||||
})),
|
||||
);
|
||||
entries.sort((left, right) => right.metadata.mtimeMs - left.metadata.mtimeMs);
|
||||
const items = entries
|
||||
.map(({ name, metadata }) => ({
|
||||
id: name,
|
||||
title: name,
|
||||
description: `${String(metadata.size)} bytes`,
|
||||
status: "stored",
|
||||
updatedAt: metadata.mtime.toISOString(),
|
||||
href: "/ase-next/content/media/library",
|
||||
}))
|
||||
.filter((item) => matchesListSearch(input, item));
|
||||
return response(input, items, items.length);
|
||||
}
|
||||
|
||||
async function brand(
|
||||
input: NormalizedContentQueryInput,
|
||||
): Promise<ContentQueryData> {
|
||||
const [{ siteSettings }, { listCustomThemes }] = await Promise.all([
|
||||
import("@/lib/services/site-settings"),
|
||||
import("@/lib/theme-custom-store"),
|
||||
]);
|
||||
if (input.routeId === "content.brand.favicon") {
|
||||
const favicon = await siteSettings.get("cms_favicon", null);
|
||||
return response(input, [
|
||||
{
|
||||
id: "favicon",
|
||||
title: favicon || "Default favicon",
|
||||
status: favicon ? "custom" : "default",
|
||||
href: "/ase-next/content/brand/favicon",
|
||||
},
|
||||
]);
|
||||
}
|
||||
const [preset, customThemes] = await Promise.all([
|
||||
siteSettings.get("theme_preset", "Atom (golden)"),
|
||||
listCustomThemes(),
|
||||
]);
|
||||
return response(input, [
|
||||
{
|
||||
id: "active-theme",
|
||||
title: preset || "Atom (golden)",
|
||||
status: "active",
|
||||
href: "/ase-next/content/brand/theme",
|
||||
},
|
||||
...customThemes.map((theme) => ({
|
||||
id: theme.id,
|
||||
title: theme.name,
|
||||
status: "saved",
|
||||
updatedAt: new Date(theme.createdAt).toISOString(),
|
||||
href: "/ase-next/content/brand/theme",
|
||||
})),
|
||||
]);
|
||||
}
|
||||
|
||||
async function localizationFiles(
|
||||
input: NormalizedContentQueryInput,
|
||||
): Promise<ContentQueryData> {
|
||||
if (input.routeId === "content.localization.client") {
|
||||
const { CLIENT_TRANSLATION_FILES } = await import(
|
||||
"@/lib/client-translation-files"
|
||||
);
|
||||
return response(
|
||||
input,
|
||||
CLIENT_TRANSLATION_FILES.map((file) => ({
|
||||
id: file.id,
|
||||
title: file.id,
|
||||
description: file.relPath,
|
||||
status: file.readOnly ? "read-only" : "editable",
|
||||
href: "/ase-next/content/localization/client",
|
||||
})),
|
||||
);
|
||||
}
|
||||
const [{ readdir }, { join }] = await Promise.all([
|
||||
import("node:fs/promises"),
|
||||
import("node:path"),
|
||||
]);
|
||||
let locales: string[] = [];
|
||||
try {
|
||||
locales = (await readdir(join(process.cwd(), "src", "messages")))
|
||||
.filter((name) => name.endsWith(".json"))
|
||||
.map((name) => name.slice(0, -5))
|
||||
.sort();
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
if (input.routeId === "content.localization.overview") {
|
||||
return response(input, [
|
||||
{
|
||||
id: "cms",
|
||||
title: "CMS translations",
|
||||
status: `${String(locales.length)} locales`,
|
||||
href: "/ase-next/content/localization/cms",
|
||||
},
|
||||
{
|
||||
id: "client",
|
||||
title: "Client translations",
|
||||
status: "configured sources",
|
||||
href: "/ase-next/content/localization/client",
|
||||
},
|
||||
{
|
||||
id: "emulator",
|
||||
title: "Emulator translations",
|
||||
status: "database store",
|
||||
href: "/ase-next/content/localization/emulator",
|
||||
},
|
||||
]);
|
||||
}
|
||||
return response(
|
||||
input,
|
||||
locales.map((locale) => ({
|
||||
id: locale,
|
||||
title: locale,
|
||||
status: "editable",
|
||||
href: "/ase-next/content/localization/cms",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadProductionContentQuery(
|
||||
input: NormalizedContentQueryInput,
|
||||
): Promise<ContentQueryData> {
|
||||
if (EMPTY_ROUTES.has(input.routeId)) return response(input, []);
|
||||
if (input.routeId === "content.media.library") return mediaLibrary(input);
|
||||
if (input.routeId.startsWith("content.brand.")) return brand(input);
|
||||
if (
|
||||
input.routeId === "content.localization.overview" ||
|
||||
input.routeId === "content.localization.client" ||
|
||||
input.routeId === "content.localization.cms"
|
||||
) {
|
||||
return localizationFiles(input);
|
||||
}
|
||||
const definition = (
|
||||
CONTENT_QUERY_DEFINITIONS as Partial<
|
||||
Record<ContentRouteId, QueryDefinition>
|
||||
>
|
||||
)[input.routeId];
|
||||
if (!definition) throw new Error("missing Content query adapter");
|
||||
return databaseRoute(input, definition);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||
import { CONTENT_ROUTE_IDS } from "../routes";
|
||||
import {
|
||||
type ContentQueryData,
|
||||
type ContentQueryInput,
|
||||
createContentQuery,
|
||||
} from "./content-queries";
|
||||
import {
|
||||
CONTENT_QUERY_DEFINITIONS,
|
||||
loadProductionContentQuery,
|
||||
} from "./content-queries-production";
|
||||
|
||||
const queryMocks = vi.hoisted(() => ({
|
||||
execute: vi.fn(),
|
||||
join: vi.fn((chunks: unknown[], separator: unknown) => ({
|
||||
chunks,
|
||||
separator,
|
||||
})),
|
||||
raw: vi.fn((statement: string) => ({ statement })),
|
||||
tagged: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({
|
||||
strings: Array.from(strings),
|
||||
values,
|
||||
})),
|
||||
}));
|
||||
vi.mock("drizzle-orm", () => ({
|
||||
sql: Object.assign(queryMocks.tagged, {
|
||||
join: queryMocks.join,
|
||||
raw: queryMocks.raw,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({ db: { execute: queryMocks.execute } }));
|
||||
|
||||
function context(granted: readonly string[]): HousekeepingCapabilityContext {
|
||||
const permissions = new Set(granted);
|
||||
return {
|
||||
actor: { id: 42, username: "operator", rank: 7 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => permissions.has(slug),
|
||||
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
||||
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
||||
};
|
||||
}
|
||||
|
||||
const ready: ContentQueryData = {
|
||||
kind: "editorial",
|
||||
items: [
|
||||
{
|
||||
id: "18446744073709551615",
|
||||
title: "Published article",
|
||||
status: "published",
|
||||
href: "/ase-next/content/editorial/articles/18446744073709551615",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
partialDependencies: [],
|
||||
};
|
||||
|
||||
describe("Content query", () => {
|
||||
it("reports totals beyond the former 500-row adapter cap", async () => {
|
||||
const pageRows = Array.from({ length: 25 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
title: `Article ${index + 1}`,
|
||||
status: "published",
|
||||
updated_at: null,
|
||||
}));
|
||||
queryMocks.execute
|
||||
.mockResolvedValueOnce([[{ total: 600 }]])
|
||||
.mockResolvedValueOnce([pageRows]);
|
||||
const result = await loadProductionContentQuery({
|
||||
routeId: "content.editorial.articles",
|
||||
params: {},
|
||||
list: { search: "", pageSize: 25, offset: 0 },
|
||||
});
|
||||
expect(result.total).toBe(600);
|
||||
expect(result.items).toHaveLength(25);
|
||||
expect(queryMocks.execute).toHaveBeenCalledTimes(2);
|
||||
const pageQuery = queryMocks.execute.mock.calls[1]?.[0];
|
||||
expect(JSON.stringify(pageQuery)).toContain("25");
|
||||
expect(JSON.stringify(pageQuery)).toContain("0");
|
||||
});
|
||||
|
||||
it("binds search, limit, and offset into the bounded page query", async () => {
|
||||
queryMocks.execute
|
||||
.mockResolvedValueOnce([[{ total: 1 }]])
|
||||
.mockResolvedValueOnce([
|
||||
[{ id: 9, title: "Launch", status: "published", updated_at: null }],
|
||||
]);
|
||||
await loadProductionContentQuery({
|
||||
routeId: "content.editorial.articles",
|
||||
params: {},
|
||||
list: { search: "Launch", pageSize: 7, offset: 14 },
|
||||
});
|
||||
const serialized = JSON.stringify(queryMocks.execute.mock.calls);
|
||||
expect(serialized).toContain("%launch%");
|
||||
expect(serialized).toContain("7");
|
||||
expect(serialized).toContain("14");
|
||||
});
|
||||
|
||||
it("searches only projected aliases for routes without descriptions", async () => {
|
||||
queryMocks.execute
|
||||
.mockResolvedValueOnce([[{ total: 0 }]])
|
||||
.mockResolvedValueOnce([[]]);
|
||||
await loadProductionContentQuery({
|
||||
routeId: "content.engagement.events",
|
||||
params: {},
|
||||
list: { search: "launch", pageSize: 25, offset: 0 },
|
||||
});
|
||||
const serialized = JSON.stringify(queryMocks.execute.mock.calls.slice(-2));
|
||||
expect(serialized).toContain("COALESCE(title");
|
||||
expect(serialized).not.toContain("COALESCE(description");
|
||||
});
|
||||
|
||||
it("never selects email template bodies into summary query results", () => {
|
||||
const emailTemplates =
|
||||
CONTENT_QUERY_DEFINITIONS["content.help.email-templates"];
|
||||
expect(emailTemplates.statement).not.toMatch(/\bbody\b|private_payload/iu);
|
||||
});
|
||||
|
||||
it("fails closed before production adapters are invoked", async () => {
|
||||
const load = vi.fn(async () => ready);
|
||||
const query = createContentQuery({ load });
|
||||
|
||||
const result = await query.run(context([]), {
|
||||
routeId: "content.editorial.articles",
|
||||
list: { search: "", pageSize: 25, offset: 0 },
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "FORBIDDEN" },
|
||||
});
|
||||
expect(load).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bounds list input, preserves canonical BIGINT strings, and returns real adapter data", async () => {
|
||||
const load = vi.fn(async (_input: ContentQueryInput) => ready);
|
||||
const query = createContentQuery({ load });
|
||||
|
||||
const result = await query.run(context([PERMS.NEWS_VIEW]), {
|
||||
routeId: "content.editorial.articles",
|
||||
list: {
|
||||
search: " launch ",
|
||||
pageSize: 1000,
|
||||
offset: 999_999,
|
||||
},
|
||||
});
|
||||
|
||||
expect(load).toHaveBeenCalledWith({
|
||||
routeId: "content.editorial.articles",
|
||||
params: {},
|
||||
list: { search: "launch", pageSize: 100, offset: 100_000 },
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
data: {
|
||||
items: [{ id: "18446744073709551615" }],
|
||||
total: 1,
|
||||
partialDependencies: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a fake partial result and maps complete adapter failure", async () => {
|
||||
const malformed = createContentQuery({
|
||||
load: async () =>
|
||||
({
|
||||
kind: "media",
|
||||
items: [],
|
||||
total: 0,
|
||||
partialDependencies: ["imaginary"],
|
||||
}) as ContentQueryData,
|
||||
});
|
||||
const unavailable = createContentQuery({
|
||||
load: async () => {
|
||||
throw new Error("database unavailable");
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
await malformed.run(context([PERMS.PAGES_VIEW]), {
|
||||
routeId: "content.media.library",
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
||||
});
|
||||
expect(
|
||||
await unavailable.run(context([PERMS.PAGES_VIEW]), {
|
||||
routeId: "content.media.library",
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
||||
});
|
||||
});
|
||||
|
||||
it("has an authorized production query path for every Content route", async () => {
|
||||
const load = vi.fn(async (input: ContentQueryInput) => ({
|
||||
kind: input.routeId.split(".")[1] as ContentQueryData["kind"],
|
||||
items: [],
|
||||
total: 0,
|
||||
partialDependencies: [],
|
||||
}));
|
||||
const query = createContentQuery({ load });
|
||||
const all = context(Object.values(PERMS));
|
||||
|
||||
for (const routeId of CONTENT_ROUTE_IDS) {
|
||||
const result = await query.run(all, { routeId });
|
||||
expect(result.ok, routeId).toBe(true);
|
||||
}
|
||||
expect(load).toHaveBeenCalledTimes(CONTENT_ROUTE_IDS.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { authorizeHousekeeping } from "../../../foundation/authorization";
|
||||
import {
|
||||
fail,
|
||||
type HousekeepingQuery,
|
||||
ok,
|
||||
} from "../../../foundation/contracts";
|
||||
import { isSafeHousekeepingHref } from "../../../foundation/housekeeping-href";
|
||||
import {
|
||||
type ContentRouteGroup,
|
||||
type ContentRouteId,
|
||||
contentRouteById,
|
||||
contentRouteGroup,
|
||||
} from "../routes";
|
||||
|
||||
export interface ContentQueryListInput {
|
||||
readonly search?: string;
|
||||
readonly pageSize?: number;
|
||||
readonly offset?: number;
|
||||
}
|
||||
|
||||
export interface ContentQueryInput {
|
||||
readonly routeId: ContentRouteId;
|
||||
readonly params?: Readonly<Record<string, string>>;
|
||||
readonly list?: ContentQueryListInput;
|
||||
}
|
||||
|
||||
export interface ContentQueryItem {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly status?: string;
|
||||
readonly description?: string;
|
||||
readonly href?: string;
|
||||
readonly updatedAt?: string | null;
|
||||
readonly privatePayload?: unknown;
|
||||
}
|
||||
|
||||
export interface ContentQueryData {
|
||||
readonly kind: ContentRouteGroup;
|
||||
readonly items: readonly ContentQueryItem[];
|
||||
readonly total: number;
|
||||
readonly partialDependencies: readonly string[];
|
||||
}
|
||||
|
||||
export interface NormalizedContentQueryInput {
|
||||
readonly routeId: ContentRouteId;
|
||||
readonly params: Readonly<Record<string, string>>;
|
||||
readonly list: Readonly<{
|
||||
search: string;
|
||||
pageSize: number;
|
||||
offset: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ContentQueryAdapters {
|
||||
load(input: NormalizedContentQueryInput): Promise<ContentQueryData>;
|
||||
}
|
||||
|
||||
function boundedInteger(
|
||||
value: unknown,
|
||||
fallback: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed)) return fallback;
|
||||
return Math.min(maximum, Math.max(minimum, parsed));
|
||||
}
|
||||
|
||||
function normalizeInput(input: ContentQueryInput): NormalizedContentQueryInput {
|
||||
return {
|
||||
routeId: input.routeId,
|
||||
params: Object.fromEntries(
|
||||
Object.entries(input.params ?? {}).map(([key, value]) => [
|
||||
key.normalize("NFC").trim().slice(0, 128),
|
||||
value.normalize("NFC").trim().slice(0, 128),
|
||||
]),
|
||||
),
|
||||
list: {
|
||||
search: String(input.list?.search ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 128),
|
||||
pageSize: boundedInteger(input.list?.pageSize, 25, 1, 100),
|
||||
offset: boundedInteger(input.list?.offset, 0, 0, 100_000),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isValidData(
|
||||
data: ContentQueryData,
|
||||
input: NormalizedContentQueryInput,
|
||||
): boolean {
|
||||
if (
|
||||
data.kind !== contentRouteGroup(input.routeId) ||
|
||||
!Array.isArray(data.items) ||
|
||||
!Number.isSafeInteger(data.total) ||
|
||||
data.total < 0 ||
|
||||
!Array.isArray(data.partialDependencies) ||
|
||||
data.partialDependencies.length !== 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return data.items.every(
|
||||
(item) =>
|
||||
typeof item.id === "string" &&
|
||||
item.id.length > 0 &&
|
||||
typeof item.title === "string" &&
|
||||
item.title.length > 0 &&
|
||||
(item.href === undefined || isSafeHousekeepingHref(item.href)),
|
||||
);
|
||||
}
|
||||
|
||||
export function createContentQuery(
|
||||
adapters: ContentQueryAdapters,
|
||||
): HousekeepingQuery<ContentQueryInput, ContentQueryData> {
|
||||
return {
|
||||
id: "content.query",
|
||||
owner: "content",
|
||||
capability: contentRouteById("content.editorial.articles").capability,
|
||||
async run(context, input) {
|
||||
const route = contentRouteById(input.routeId);
|
||||
const authorization = authorizeHousekeeping(context, route.capability);
|
||||
if (!authorization.ok) return authorization;
|
||||
const normalized = normalizeInput(input);
|
||||
try {
|
||||
const data = await adapters.load(normalized);
|
||||
if (!isValidData(data, normalized)) {
|
||||
throw new Error("invalid Content query data");
|
||||
}
|
||||
return ok(data, authorization.correlationId);
|
||||
} catch {
|
||||
return fail(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
authorization.correlationId,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const contentQuery = createContentQuery({
|
||||
async load(input) {
|
||||
const { loadProductionContentQuery } = await import(
|
||||
"./content-queries-production"
|
||||
);
|
||||
return loadProductionContentQuery(input);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { HousekeepingRouteHandler } from "../../route-handlers";
|
||||
import { renderContentBrandPage } from "./pages/brand";
|
||||
import { renderContentEditorialPage } from "./pages/editorial";
|
||||
import { renderContentEngagementPage } from "./pages/engagement";
|
||||
import { renderContentHelpPage } from "./pages/help";
|
||||
import { renderContentLocalizationPage } from "./pages/localization";
|
||||
import { renderContentMediaPage } from "./pages/media";
|
||||
import {
|
||||
CONTENT_ROUTE_IDS,
|
||||
type ContentRouteId,
|
||||
contentRouteGroup,
|
||||
} from "./routes";
|
||||
|
||||
function rendererFor(
|
||||
routeId: ContentRouteId,
|
||||
): HousekeepingRouteHandler["render"] {
|
||||
const group = contentRouteGroup(routeId);
|
||||
if (group === "editorial") return renderContentEditorialPage;
|
||||
if (group === "media") return renderContentMediaPage;
|
||||
if (group === "engagement") return renderContentEngagementPage;
|
||||
if (group === "help") return renderContentHelpPage;
|
||||
if (group === "brand") return renderContentBrandPage;
|
||||
return renderContentLocalizationPage;
|
||||
}
|
||||
|
||||
export const CONTENT_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[] =
|
||||
Object.freeze(
|
||||
CONTENT_ROUTE_IDS.map((routeId) =>
|
||||
Object.freeze({ routeId, render: rendererFor(routeId) }),
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { HOUSEKEEPING_MANIFESTS } from "../../manifests";
|
||||
import { contentMigrationEntries } from "../../migration/content";
|
||||
import { HOUSEKEEPING_ROUTE_HANDLERS } from "../../route-handlers";
|
||||
import { contentManifest } from "./manifest";
|
||||
import {
|
||||
CONTENT_ROUTE_GROUPS,
|
||||
CONTENT_ROUTE_IDS,
|
||||
CONTENT_ROUTES,
|
||||
type ContentRouteId,
|
||||
contentRouteGroup,
|
||||
} from "./routes";
|
||||
|
||||
const expected = [
|
||||
[
|
||||
"content.editorial.articles",
|
||||
"/ase-next/content/editorial/articles",
|
||||
"editorial",
|
||||
],
|
||||
[
|
||||
"content.editorial.article-create",
|
||||
"/ase-next/content/editorial/articles/new",
|
||||
"editorial",
|
||||
],
|
||||
[
|
||||
"content.editorial.article-detail",
|
||||
"/ase-next/content/editorial/articles/:id",
|
||||
"editorial",
|
||||
],
|
||||
["content.media.photos", "/ase-next/content/media/photos", "media"],
|
||||
["content.media.library", "/ase-next/content/media/library", "media"],
|
||||
["content.media.banners", "/ase-next/content/media/banners", "media"],
|
||||
["content.media.ads", "/ase-next/content/media/ads", "media"],
|
||||
["content.media.ad-create", "/ase-next/content/media/ads/new", "media"],
|
||||
["content.media.ad-detail", "/ase-next/content/media/ads/:id", "media"],
|
||||
["content.engagement.events", "/ase-next/content/engagement/events", "engagement"],
|
||||
[
|
||||
"content.engagement.event-create",
|
||||
"/ase-next/content/engagement/events/create",
|
||||
"engagement",
|
||||
],
|
||||
[
|
||||
"content.engagement.event-types",
|
||||
"/ase-next/content/engagement/events/types",
|
||||
"engagement",
|
||||
],
|
||||
[
|
||||
"content.engagement.event-detail",
|
||||
"/ase-next/content/engagement/events/:id",
|
||||
"engagement",
|
||||
],
|
||||
["content.engagement.polls", "/ase-next/content/engagement/polls", "engagement"],
|
||||
[
|
||||
"content.engagement.poll-create",
|
||||
"/ase-next/content/engagement/polls/create",
|
||||
"engagement",
|
||||
],
|
||||
[
|
||||
"content.engagement.poll-detail",
|
||||
"/ase-next/content/engagement/polls/:id",
|
||||
"engagement",
|
||||
],
|
||||
["content.help.questions", "/ase-next/content/help/questions", "help"],
|
||||
["content.help.question-create", "/ase-next/content/help/questions/new", "help"],
|
||||
["content.help.question-detail", "/ase-next/content/help/questions/:id", "help"],
|
||||
["content.editorial.tags", "/ase-next/content/editorial/tags", "editorial"],
|
||||
[
|
||||
"content.engagement.prefixes",
|
||||
"/ase-next/content/engagement/prefixes",
|
||||
"engagement",
|
||||
],
|
||||
[
|
||||
"content.editorial.writeable-boxes",
|
||||
"/ase-next/content/editorial/writeable-boxes",
|
||||
"editorial",
|
||||
],
|
||||
["content.help.email-templates", "/ase-next/content/help/email-templates", "help"],
|
||||
["content.brand.theme", "/ase-next/content/brand/theme", "brand"],
|
||||
[
|
||||
"content.brand.theme-builder",
|
||||
"/ase-next/content/brand/theme-builder",
|
||||
"brand",
|
||||
],
|
||||
["content.brand.favicon", "/ase-next/content/brand/favicon", "brand"],
|
||||
[
|
||||
"content.localization.overview",
|
||||
"/ase-next/content/localization",
|
||||
"localization",
|
||||
],
|
||||
[
|
||||
"content.localization.client",
|
||||
"/ase-next/content/localization/client",
|
||||
"localization",
|
||||
],
|
||||
["content.localization.cms", "/ase-next/content/localization/cms", "localization"],
|
||||
[
|
||||
"content.localization.emulator",
|
||||
"/ase-next/content/localization/emulator",
|
||||
"localization",
|
||||
],
|
||||
] as const;
|
||||
|
||||
describe("Content routes", () => {
|
||||
it("declares the six exact route groups", () => {
|
||||
expect(CONTENT_ROUTE_GROUPS).toEqual([
|
||||
"editorial",
|
||||
"media",
|
||||
"engagement",
|
||||
"help",
|
||||
"brand",
|
||||
"localization",
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps every migration row to its canonical route and group", () => {
|
||||
expect(
|
||||
CONTENT_ROUTES.map((route) => [
|
||||
route.id,
|
||||
route.href,
|
||||
contentRouteGroup(route.id as ContentRouteId),
|
||||
]),
|
||||
).toEqual(expected);
|
||||
expect(CONTENT_ROUTE_IDS).toEqual(expected.map(([id]) => id));
|
||||
expect(CONTENT_ROUTES.map((route) => route.href).sort()).toEqual(
|
||||
contentMigrationEntries
|
||||
.filter((entry) => entry.targetPath !== null)
|
||||
.map((entry) => entry.targetPath)
|
||||
.sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps manifest routes and real handlers in exact equality", () => {
|
||||
expect(contentManifest.routes).toBe(CONTENT_ROUTES);
|
||||
expect(contentManifest.routes.map((route) => route.id)).toEqual(
|
||||
HOUSEKEEPING_ROUTE_HANDLERS.filter((handler) =>
|
||||
handler.routeId.startsWith("content."),
|
||||
).map((handler) => handler.routeId),
|
||||
);
|
||||
expect(
|
||||
HOUSEKEEPING_MANIFESTS.flatMap((manifest) =>
|
||||
manifest.routes.map((route) => route.id),
|
||||
),
|
||||
).toEqual(HOUSEKEEPING_ROUTE_HANDLERS.map((handler) => handler.routeId));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import {
|
||||
anyCapability,
|
||||
type CanonicalHousekeepingHref,
|
||||
type HousekeepingRouteDefinition,
|
||||
} from "../../foundation/contracts";
|
||||
|
||||
export const CONTENT_ROUTE_GROUPS = [
|
||||
"editorial",
|
||||
"media",
|
||||
"engagement",
|
||||
"help",
|
||||
"brand",
|
||||
"localization",
|
||||
] as const;
|
||||
|
||||
export type ContentRouteGroup = (typeof CONTENT_ROUTE_GROUPS)[number];
|
||||
|
||||
export const CONTENT_ROUTE_IDS = [
|
||||
"content.editorial.articles",
|
||||
"content.editorial.article-create",
|
||||
"content.editorial.article-detail",
|
||||
"content.media.photos",
|
||||
"content.media.library",
|
||||
"content.media.banners",
|
||||
"content.media.ads",
|
||||
"content.media.ad-create",
|
||||
"content.media.ad-detail",
|
||||
"content.engagement.events",
|
||||
"content.engagement.event-create",
|
||||
"content.engagement.event-types",
|
||||
"content.engagement.event-detail",
|
||||
"content.engagement.polls",
|
||||
"content.engagement.poll-create",
|
||||
"content.engagement.poll-detail",
|
||||
"content.help.questions",
|
||||
"content.help.question-create",
|
||||
"content.help.question-detail",
|
||||
"content.editorial.tags",
|
||||
"content.engagement.prefixes",
|
||||
"content.editorial.writeable-boxes",
|
||||
"content.help.email-templates",
|
||||
"content.brand.theme",
|
||||
"content.brand.theme-builder",
|
||||
"content.brand.favicon",
|
||||
"content.localization.overview",
|
||||
"content.localization.client",
|
||||
"content.localization.cms",
|
||||
"content.localization.emulator",
|
||||
] as const;
|
||||
|
||||
export type ContentRouteId = (typeof CONTENT_ROUTE_IDS)[number];
|
||||
|
||||
function contentRoute(
|
||||
id: ContentRouteId,
|
||||
href: CanonicalHousekeepingHref,
|
||||
capabilities: readonly string[],
|
||||
): HousekeepingRouteDefinition {
|
||||
return {
|
||||
id,
|
||||
labelKey: `pages.housekeeping.routes.${id}`,
|
||||
href,
|
||||
capability: anyCapability(...capabilities),
|
||||
};
|
||||
}
|
||||
|
||||
export const CONTENT_ROUTES = [
|
||||
contentRoute(
|
||||
"content.editorial.articles",
|
||||
"/ase-next/content/editorial/articles",
|
||||
[PERMS.NEWS_VIEW],
|
||||
),
|
||||
contentRoute(
|
||||
"content.editorial.article-create",
|
||||
"/ase-next/content/editorial/articles/new",
|
||||
[PERMS.NEWS_EDIT],
|
||||
),
|
||||
contentRoute(
|
||||
"content.editorial.article-detail",
|
||||
"/ase-next/content/editorial/articles/:id",
|
||||
[PERMS.NEWS_VIEW],
|
||||
),
|
||||
contentRoute("content.media.photos", "/ase-next/content/media/photos", [
|
||||
PERMS.PAGES_VIEW,
|
||||
]),
|
||||
contentRoute("content.media.library", "/ase-next/content/media/library", [
|
||||
PERMS.PAGES_VIEW,
|
||||
]),
|
||||
contentRoute("content.media.banners", "/ase-next/content/media/banners", [
|
||||
PERMS.BANNERS_VIEW,
|
||||
]),
|
||||
contentRoute("content.media.ads", "/ase-next/content/media/ads", [
|
||||
PERMS.PAGES_VIEW,
|
||||
]),
|
||||
contentRoute("content.media.ad-create", "/ase-next/content/media/ads/new", [
|
||||
PERMS.PAGES_EDIT,
|
||||
]),
|
||||
contentRoute("content.media.ad-detail", "/ase-next/content/media/ads/:id", [
|
||||
PERMS.PAGES_VIEW,
|
||||
]),
|
||||
contentRoute("content.engagement.events", "/ase-next/content/engagement/events", [
|
||||
PERMS.EVENTS_VIEW,
|
||||
]),
|
||||
contentRoute(
|
||||
"content.engagement.event-create",
|
||||
"/ase-next/content/engagement/events/create",
|
||||
[PERMS.EVENTS_EDIT],
|
||||
),
|
||||
contentRoute(
|
||||
"content.engagement.event-types",
|
||||
"/ase-next/content/engagement/events/types",
|
||||
[PERMS.EVENTS_EDIT],
|
||||
),
|
||||
contentRoute(
|
||||
"content.engagement.event-detail",
|
||||
"/ase-next/content/engagement/events/:id",
|
||||
[PERMS.EVENTS_EDIT],
|
||||
),
|
||||
contentRoute("content.engagement.polls", "/ase-next/content/engagement/polls", [
|
||||
PERMS.POLLS_VIEW,
|
||||
]),
|
||||
contentRoute(
|
||||
"content.engagement.poll-create",
|
||||
"/ase-next/content/engagement/polls/create",
|
||||
[PERMS.POLLS_EDIT],
|
||||
),
|
||||
contentRoute(
|
||||
"content.engagement.poll-detail",
|
||||
"/ase-next/content/engagement/polls/:id",
|
||||
[PERMS.POLLS_EDIT],
|
||||
),
|
||||
contentRoute("content.help.questions", "/ase-next/content/help/questions", [
|
||||
PERMS.PAGES_VIEW,
|
||||
]),
|
||||
contentRoute(
|
||||
"content.help.question-create",
|
||||
"/ase-next/content/help/questions/new",
|
||||
[PERMS.PAGES_EDIT],
|
||||
),
|
||||
contentRoute(
|
||||
"content.help.question-detail",
|
||||
"/ase-next/content/help/questions/:id",
|
||||
[PERMS.PAGES_VIEW],
|
||||
),
|
||||
contentRoute("content.editorial.tags", "/ase-next/content/editorial/tags", [
|
||||
PERMS.PAGES_VIEW,
|
||||
]),
|
||||
contentRoute(
|
||||
"content.engagement.prefixes",
|
||||
"/ase-next/content/engagement/prefixes",
|
||||
[PERMS.PREFIXES_VIEW],
|
||||
),
|
||||
contentRoute(
|
||||
"content.editorial.writeable-boxes",
|
||||
"/ase-next/content/editorial/writeable-boxes",
|
||||
[PERMS.PAGES_VIEW],
|
||||
),
|
||||
contentRoute(
|
||||
"content.help.email-templates",
|
||||
"/ase-next/content/help/email-templates",
|
||||
[PERMS.PAGES_VIEW],
|
||||
),
|
||||
contentRoute("content.brand.theme", "/ase-next/content/brand/theme", [
|
||||
PERMS.SETTINGS_VIEW,
|
||||
]),
|
||||
contentRoute(
|
||||
"content.brand.theme-builder",
|
||||
"/ase-next/content/brand/theme-builder",
|
||||
[PERMS.SETTINGS_VIEW],
|
||||
),
|
||||
contentRoute("content.brand.favicon", "/ase-next/content/brand/favicon", [
|
||||
PERMS.SETTINGS_VIEW,
|
||||
]),
|
||||
contentRoute("content.localization.overview", "/ase-next/content/localization", [
|
||||
PERMS.SETTINGS_VIEW,
|
||||
]),
|
||||
contentRoute(
|
||||
"content.localization.client",
|
||||
"/ase-next/content/localization/client",
|
||||
[PERMS.SETTINGS_VIEW],
|
||||
),
|
||||
contentRoute("content.localization.cms", "/ase-next/content/localization/cms", [
|
||||
PERMS.SETTINGS_VIEW,
|
||||
]),
|
||||
contentRoute(
|
||||
"content.localization.emulator",
|
||||
"/ase-next/content/localization/emulator",
|
||||
[PERMS.SETTINGS_VIEW],
|
||||
),
|
||||
] as const satisfies readonly HousekeepingRouteDefinition[];
|
||||
|
||||
const routeGroups = new Map<ContentRouteId, ContentRouteGroup>(
|
||||
CONTENT_ROUTE_IDS.map((routeId) => [
|
||||
routeId,
|
||||
routeId.split(".")[1] as ContentRouteGroup,
|
||||
]),
|
||||
);
|
||||
|
||||
export function contentRouteGroup(routeId: ContentRouteId): ContentRouteGroup {
|
||||
const group = routeGroups.get(routeId);
|
||||
if (!group) throw new Error(`unknown Content route: ${routeId}`);
|
||||
return group;
|
||||
}
|
||||
|
||||
export function contentRouteById(
|
||||
routeId: ContentRouteId,
|
||||
): (typeof CONTENT_ROUTES)[number] {
|
||||
const route = CONTENT_ROUTES.find((candidate) => candidate.id === routeId);
|
||||
if (!route) throw new Error(`unknown Content route: ${routeId}`);
|
||||
return route;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import "server-only";
|
||||
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import {
|
||||
anyCapability,
|
||||
type HousekeepingCapabilityContext,
|
||||
} from "../../foundation/contracts";
|
||||
import { contentQuery } from "./queries/content-queries";
|
||||
import type { ContentSearchCandidate } from "./search";
|
||||
|
||||
type ContentSearchKind = "articles" | "events" | "media" | "help";
|
||||
|
||||
const SEARCH_ROUTES = {
|
||||
articles: [["content.editorial.articles", anyCapability(PERMS.NEWS_VIEW)]],
|
||||
events: [["content.engagement.events", anyCapability(PERMS.EVENTS_VIEW)]],
|
||||
media: [
|
||||
["content.media.photos", anyCapability(PERMS.PAGES_VIEW)],
|
||||
["content.media.library", anyCapability(PERMS.PAGES_VIEW)],
|
||||
["content.media.banners", anyCapability(PERMS.BANNERS_VIEW)],
|
||||
["content.media.ads", anyCapability(PERMS.PAGES_VIEW)],
|
||||
],
|
||||
help: [
|
||||
["content.help.questions", anyCapability(PERMS.PAGES_VIEW)],
|
||||
["content.help.email-templates", anyCapability(PERMS.PAGES_VIEW)],
|
||||
],
|
||||
} as const;
|
||||
|
||||
export async function loadContentSearchCandidates(
|
||||
kind: ContentSearchKind,
|
||||
context: HousekeepingCapabilityContext,
|
||||
term: string,
|
||||
limit: number,
|
||||
): Promise<readonly ContentSearchCandidate[]> {
|
||||
const candidates: ContentSearchCandidate[] = [];
|
||||
for (const [routeId, capability] of SEARCH_ROUTES[kind]) {
|
||||
const result = await contentQuery.run(context, {
|
||||
routeId,
|
||||
list: { search: term, pageSize: limit, offset: 0 },
|
||||
});
|
||||
if (!result.ok) continue;
|
||||
for (const item of result.data.items) {
|
||||
if (!item.href) continue;
|
||||
candidates.push({
|
||||
id: `${routeId}:${item.id}`,
|
||||
title: item.title,
|
||||
description: item.description ?? item.status,
|
||||
href: item.href,
|
||||
capability,
|
||||
});
|
||||
if (candidates.length >= limit) return candidates;
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { authorizeHousekeeping } from "../../foundation/authorization";
|
||||
import { satisfiesCapability } from "../../foundation/capability-context";
|
||||
import {
|
||||
anyCapability,
|
||||
type CapabilityRequirement,
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
type HousekeepingSearchProvider,
|
||||
ok,
|
||||
} from "../../foundation/contracts";
|
||||
import { isSafeHousekeepingHref } from "../../foundation/housekeeping-href";
|
||||
|
||||
export const CONTENT_SEARCH_PROVIDER_IDS = [
|
||||
"content.articles",
|
||||
"content.events",
|
||||
"content.media",
|
||||
"content.help",
|
||||
] as const;
|
||||
|
||||
export interface ContentSearchCandidate {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly description?: string;
|
||||
readonly href: string;
|
||||
readonly capability: CapabilityRequirement;
|
||||
}
|
||||
|
||||
type ContentSearchLoader = (
|
||||
context: HousekeepingCapabilityContext,
|
||||
term: string,
|
||||
limit: number,
|
||||
) => Promise<readonly ContentSearchCandidate[]>;
|
||||
|
||||
export interface ContentSearchAdapters {
|
||||
readonly articles: ContentSearchLoader;
|
||||
readonly events: ContentSearchLoader;
|
||||
readonly media: ContentSearchLoader;
|
||||
readonly help: ContentSearchLoader;
|
||||
}
|
||||
|
||||
function createProvider(
|
||||
id: (typeof CONTENT_SEARCH_PROVIDER_IDS)[number],
|
||||
capability: CapabilityRequirement,
|
||||
load: ContentSearchLoader,
|
||||
): HousekeepingSearchProvider {
|
||||
return {
|
||||
id,
|
||||
owner: "content",
|
||||
capability,
|
||||
async search(context, input) {
|
||||
const authorization = authorizeHousekeeping(context, capability);
|
||||
if (!authorization.ok) return authorization;
|
||||
const limit = Number.isFinite(input.limit)
|
||||
? Math.min(25, Math.max(1, Math.trunc(input.limit)))
|
||||
: 25;
|
||||
const term = input.term.normalize("NFC").trim().slice(0, 128);
|
||||
try {
|
||||
const candidates = await load(context, term, limit);
|
||||
return ok(
|
||||
candidates
|
||||
.filter(
|
||||
(item) =>
|
||||
isSafeHousekeepingHref(item.href) &&
|
||||
satisfiesCapability(context, item.capability),
|
||||
)
|
||||
.slice(0, limit)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
domain: "content" as const,
|
||||
type: "entity" as const,
|
||||
href: item.href as `/ase-next/${string}`,
|
||||
})),
|
||||
authorization.correlationId,
|
||||
);
|
||||
} catch {
|
||||
return fail(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
authorization.correlationId,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createContentSearchProviders(
|
||||
adapters: ContentSearchAdapters,
|
||||
): readonly HousekeepingSearchProvider[] {
|
||||
return [
|
||||
createProvider(
|
||||
"content.articles",
|
||||
anyCapability(PERMS.NEWS_VIEW),
|
||||
adapters.articles,
|
||||
),
|
||||
createProvider(
|
||||
"content.events",
|
||||
anyCapability(PERMS.EVENTS_VIEW),
|
||||
adapters.events,
|
||||
),
|
||||
createProvider(
|
||||
"content.media",
|
||||
anyCapability(PERMS.PAGES_VIEW, PERMS.BANNERS_VIEW),
|
||||
adapters.media,
|
||||
),
|
||||
createProvider(
|
||||
"content.help",
|
||||
anyCapability(PERMS.PAGES_VIEW),
|
||||
adapters.help,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export const CONTENT_SEARCH_PROVIDERS = createContentSearchProviders({
|
||||
async articles(context, term, limit) {
|
||||
const { loadContentSearchCandidates } = await import("./search-production");
|
||||
return loadContentSearchCandidates("articles", context, term, limit);
|
||||
},
|
||||
async events(context, term, limit) {
|
||||
const { loadContentSearchCandidates } = await import("./search-production");
|
||||
return loadContentSearchCandidates("events", context, term, limit);
|
||||
},
|
||||
async media(context, term, limit) {
|
||||
const { loadContentSearchCandidates } = await import("./search-production");
|
||||
return loadContentSearchCandidates("media", context, term, limit);
|
||||
},
|
||||
async help(context, term, limit) {
|
||||
const { loadContentSearchCandidates } = await import("./search-production");
|
||||
return loadContentSearchCandidates("help", context, term, limit);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||
import { executeContentDatabaseMutation } from "./mutation-runtime-database";
|
||||
|
||||
const sqlMocks = vi.hoisted(() => ({
|
||||
join: vi.fn((chunks: unknown[], separator: unknown) => ({
|
||||
chunks,
|
||||
separator,
|
||||
})),
|
||||
raw: vi.fn((statement: string) => statement),
|
||||
tagged: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({
|
||||
strings: Array.from(strings),
|
||||
values,
|
||||
})),
|
||||
}));
|
||||
|
||||
const database = vi.hoisted(() => {
|
||||
let selected: Record<string, unknown> = { id: 7, title: "Existing" };
|
||||
const set = vi.fn((values: Record<string, unknown>) => ({
|
||||
where: vi.fn(async () => undefined),
|
||||
values,
|
||||
}));
|
||||
const update = vi.fn(() => ({ set }));
|
||||
const limit = vi.fn(async () => [selected]);
|
||||
const where = vi.fn(() => ({ limit }));
|
||||
const from = vi.fn(() => ({ where }));
|
||||
const select = vi.fn(() => ({ from }));
|
||||
const execute = vi.fn(async () => undefined);
|
||||
return {
|
||||
execute,
|
||||
set,
|
||||
update,
|
||||
select,
|
||||
selected(value: Record<string, unknown>) {
|
||||
selected = value;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("drizzle-orm", () => ({
|
||||
eq: vi.fn(),
|
||||
sql: Object.assign(sqlMocks.tagged, {
|
||||
join: sqlMocks.join,
|
||||
raw: sqlMocks.raw,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => {
|
||||
const table = new Proxy({}, { get: (_target, key) => String(key) });
|
||||
return {
|
||||
db: {
|
||||
execute: database.execute,
|
||||
select: database.select,
|
||||
update: database.update,
|
||||
},
|
||||
EmailTemplates: table,
|
||||
Taggables: table,
|
||||
Tags: table,
|
||||
User: table,
|
||||
WebsiteAds: table,
|
||||
WebsiteArticleComments: table,
|
||||
WebsiteArticleReactions: table,
|
||||
WebsiteArticles: table,
|
||||
WebsiteBanner: table,
|
||||
WebsiteEvent: table,
|
||||
WebsiteEventPrize: table,
|
||||
WebsiteEventType: table,
|
||||
WebsiteEventWinner: table,
|
||||
WebsiteHelpCenterCategories: table,
|
||||
WebsitePoll: table,
|
||||
WebsitePollQuestion: table,
|
||||
WebsiteWriteableBoxes: table,
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/services/staff-activity", () => ({
|
||||
logStaffActivity: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
const capability = {
|
||||
actor: { id: 42, username: "operator", rank: 7 },
|
||||
isSuperAdmin: false,
|
||||
has: () => true,
|
||||
hasAny: () => true,
|
||||
hasAll: () => true,
|
||||
} satisfies HousekeepingCapabilityContext;
|
||||
const context = {
|
||||
capability,
|
||||
correlationId: "database-runtime",
|
||||
legacy: false,
|
||||
};
|
||||
|
||||
describe("Content database mutation runtime partial updates", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
database.selected({ id: 7, title: "Existing" });
|
||||
});
|
||||
|
||||
it("does not reset omitted banner fields", async () => {
|
||||
await executeContentDatabaseMutation(
|
||||
"banner.change",
|
||||
{ action: "update", id: 7, title: "Renamed" },
|
||||
context,
|
||||
undefined,
|
||||
);
|
||||
expect(database.set).toHaveBeenCalledWith({ title: "Renamed" });
|
||||
});
|
||||
|
||||
it("does not reset a tag color omitted by the update form", async () => {
|
||||
await executeContentDatabaseMutation(
|
||||
"tag.change",
|
||||
{ action: "update", id: "7", name: "News" },
|
||||
context,
|
||||
undefined,
|
||||
);
|
||||
const values = database.set.mock.calls[0]?.[0];
|
||||
expect(values).toMatchObject({ name: "News" });
|
||||
expect(values).not.toHaveProperty("backgroundColor");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["help-question.change", { name: "Updated question" }, "name"],
|
||||
["writeable-box.change", { title: "Updated box" }, "title"],
|
||||
["email-template.change", { subject: "Updated subject" }, "subject"],
|
||||
] as const)(
|
||||
"updates only supplied fields for %s",
|
||||
async (operation, patch, expectedKey) => {
|
||||
await executeContentDatabaseMutation(
|
||||
operation,
|
||||
{ action: "update", id: "7", ...patch },
|
||||
context,
|
||||
undefined,
|
||||
);
|
||||
const values = database.set.mock.calls[0]?.[0];
|
||||
expect(values).toHaveProperty(expectedKey);
|
||||
for (const destructiveKey of [
|
||||
"content",
|
||||
"position",
|
||||
"isActive",
|
||||
"body",
|
||||
"variables",
|
||||
"imageUrl",
|
||||
]) {
|
||||
expect(values).not.toHaveProperty(destructiveKey);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["help-question.change", { answer: "Updated answer" }, "content"],
|
||||
["writeable-box.change", { content: "Updated content" }, "content"],
|
||||
["email-template.change", { body: "Updated body" }, "body"],
|
||||
] as const)(
|
||||
"accepts a non-title partial patch for %s",
|
||||
async (operation, patch, expectedKey) => {
|
||||
await executeContentDatabaseMutation(
|
||||
operation,
|
||||
{ action: "update", id: "7", ...patch },
|
||||
context,
|
||||
undefined,
|
||||
);
|
||||
expect(database.set.mock.calls[0]?.[0]).toHaveProperty(expectedKey);
|
||||
},
|
||||
);
|
||||
|
||||
it("updates only submitted prefix columns and preserves omitted icon, effect, and active", async () => {
|
||||
const snapshot = await executeContentDatabaseMutation(
|
||||
"prefix.change",
|
||||
{ action: "update", id: 7, text: "Renamed" },
|
||||
context,
|
||||
undefined,
|
||||
);
|
||||
const assignments = sqlMocks.join.mock.calls.at(-1)?.[0] as
|
||||
| Array<{ strings: string[]; values: unknown[] }>
|
||||
| undefined;
|
||||
expect(assignments).toHaveLength(1);
|
||||
expect(assignments?.[0]?.strings.join(" ")).toContain("text =");
|
||||
expect(assignments?.[0]?.strings.join(" ")).not.toMatch(
|
||||
/icon|effect|active/u,
|
||||
);
|
||||
expect(snapshot?.after).toEqual({ id: 7, text: "Renamed" });
|
||||
});
|
||||
|
||||
it("submits an explicit false prefix active patch without touching other columns", async () => {
|
||||
const snapshot = await executeContentDatabaseMutation(
|
||||
"prefix.change",
|
||||
{ action: "update", id: 7, active: false },
|
||||
context,
|
||||
undefined,
|
||||
);
|
||||
const assignments = sqlMocks.join.mock.calls.at(-1)?.[0] as
|
||||
| Array<{ strings: string[]; values: unknown[] }>
|
||||
| undefined;
|
||||
expect(assignments).toHaveLength(1);
|
||||
expect(assignments?.[0]?.strings.join(" ")).toContain("active =");
|
||||
expect(assignments?.[0]?.values).toEqual([0]);
|
||||
expect(snapshot?.after).toEqual({ id: 7, active: 0 });
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,327 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||
import { executeContentExternalMutation } from "./mutation-runtime-external";
|
||||
import type { ContentMutationFailure } from "./mutations";
|
||||
|
||||
const fsMocks = vi.hoisted(() => ({
|
||||
mkdir: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
}));
|
||||
|
||||
const dbMocks = vi.hoisted(() => {
|
||||
const onDuplicateKeyUpdate = vi.fn(async () => undefined);
|
||||
const values = vi.fn((_value: Record<string, unknown>) => ({
|
||||
onDuplicateKeyUpdate,
|
||||
}));
|
||||
const insert = vi.fn(() => ({ values }));
|
||||
const where = vi.fn(async () => undefined);
|
||||
const deleteFn = vi.fn(() => ({ where }));
|
||||
let selectedPhoto: Record<string, unknown> = {
|
||||
id: 7,
|
||||
url: "/photos/7.png",
|
||||
};
|
||||
const limit = vi.fn(async () => [selectedPhoto]);
|
||||
const selectWhere = vi.fn(() => ({ limit }));
|
||||
const from = vi.fn(() => ({ where: selectWhere }));
|
||||
const select = vi.fn(() => ({ from }));
|
||||
return {
|
||||
deleteFn,
|
||||
insert,
|
||||
limit,
|
||||
onDuplicateKeyUpdate,
|
||||
select,
|
||||
selectedPhoto(value: Record<string, unknown>) {
|
||||
selectedPhoto = value;
|
||||
},
|
||||
values,
|
||||
where,
|
||||
};
|
||||
});
|
||||
const siteMocks = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
reload: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}));
|
||||
const photoMocks = vi.hoisted(() => ({
|
||||
activity: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs/promises", () => fsMocks);
|
||||
vi.mock("drizzle-orm", () => ({ eq: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
CameraWeb: {},
|
||||
EmulatorSettings: {},
|
||||
WebsiteSetting: {},
|
||||
db: {
|
||||
delete: dbMocks.deleteFn,
|
||||
insert: dbMocks.insert,
|
||||
select: dbMocks.select,
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/services/rcon", () => ({
|
||||
rcon: { updateConfig: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/lib/services/site-settings", () => ({
|
||||
siteSettings: siteMocks,
|
||||
}));
|
||||
vi.mock("@/lib/services/staff-activity", () => ({
|
||||
logStaffActivity: photoMocks.activity,
|
||||
}));
|
||||
vi.mock("@/lib/admin/photo-files", () => ({
|
||||
tryRemoveLocalPhotoFile: photoMocks.remove,
|
||||
}));
|
||||
|
||||
const capability = {
|
||||
actor: { id: 42, username: "operator", rank: 7 },
|
||||
isSuperAdmin: false,
|
||||
has: () => true,
|
||||
hasAny: () => true,
|
||||
hasAll: () => true,
|
||||
} satisfies HousekeepingCapabilityContext;
|
||||
const context = {
|
||||
capability,
|
||||
correlationId: "external-runtime",
|
||||
legacy: false,
|
||||
};
|
||||
|
||||
describe("Content external mutation runtime", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
siteMocks.get.mockResolvedValue(undefined);
|
||||
photoMocks.activity.mockResolvedValue(undefined);
|
||||
photoMocks.remove.mockResolvedValue(true);
|
||||
dbMocks.selectedPhoto({ id: 7, url: "/photos/7.png" });
|
||||
});
|
||||
|
||||
it("preserves media upload bytes, type, size, and canonical public URL", async () => {
|
||||
const file = new File(
|
||||
[new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])],
|
||||
"photo.png",
|
||||
{ type: "image/png" },
|
||||
);
|
||||
const snapshot = await executeContentExternalMutation(
|
||||
"media.upload",
|
||||
{ file },
|
||||
context,
|
||||
);
|
||||
|
||||
expect(snapshot).toMatchObject({
|
||||
before: null,
|
||||
after: { size: file.size, type: "image/png" },
|
||||
output: { url: expect.stringMatching(/^\/api\/media\/.+[.]png$/u) },
|
||||
});
|
||||
expect(fsMocks.mkdir).toHaveBeenCalledTimes(1);
|
||||
expect(fsMocks.writeFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining("storage"),
|
||||
expect.any(Buffer),
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates a real storage failure before optimistic success", async () => {
|
||||
fsMocks.writeFile.mockRejectedValueOnce(new Error("disk offline"));
|
||||
const file = new File(
|
||||
[new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])],
|
||||
"photo.png",
|
||||
{ type: "image/png" },
|
||||
);
|
||||
|
||||
await expect(
|
||||
executeContentExternalMutation("media.upload", { file }, context),
|
||||
).rejects.toThrow("disk offline");
|
||||
});
|
||||
|
||||
it("maps normalized media traversal to validation instead of dependency failure", async () => {
|
||||
await expect(
|
||||
executeContentExternalMutation(
|
||||
"media.delete",
|
||||
{ filename: "../private.txt" },
|
||||
context,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
code: "VALIDATION",
|
||||
messageKey: "errors.housekeeping.validation",
|
||||
} satisfies Partial<ContentMutationFailure>);
|
||||
expect(fsMocks.unlink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["favicon/brand.ico", "logo/brand.png", "favicon\\brand.ico"])(
|
||||
"rejects nested media deletion outside the generic upload namespace: %s",
|
||||
async (filename) => {
|
||||
await expect(
|
||||
executeContentExternalMutation("media.delete", { filename }, context),
|
||||
).rejects.toMatchObject({
|
||||
code: "VALIDATION",
|
||||
messageKey: "errors.housekeeping.validation",
|
||||
} satisfies Partial<ContentMutationFailure>);
|
||||
expect(fsMocks.unlink).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects declared MIME, filename extension, and actual bytes that disagree", async () => {
|
||||
const disguisedSvg = new File(
|
||||
['<svg xmlns="http://www.w3.org/2000/svg"></svg>'],
|
||||
"photo.svg",
|
||||
{ type: "image/png" },
|
||||
);
|
||||
|
||||
await expect(
|
||||
executeContentExternalMutation(
|
||||
"media.upload",
|
||||
{ file: disguisedSvg },
|
||||
context,
|
||||
),
|
||||
).rejects.toMatchObject({ code: "VALIDATION" });
|
||||
expect(fsMocks.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an oversized logo before reading its bytes", async () => {
|
||||
const arrayBuffer = vi.fn();
|
||||
const file = {
|
||||
name: "logo.png",
|
||||
type: "image/png",
|
||||
size: 5 * 1024 * 1024 + 1,
|
||||
arrayBuffer,
|
||||
};
|
||||
|
||||
await expect(
|
||||
executeContentExternalMutation("logo.save", { file }, context),
|
||||
).rejects.toMatchObject({ code: "VALIDATION" });
|
||||
expect(arrayBuffer).not.toHaveBeenCalled();
|
||||
expect(fsMocks.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes a newly written favicon when the database write fails", async () => {
|
||||
dbMocks.onDuplicateKeyUpdate.mockRejectedValueOnce(
|
||||
new Error("database offline"),
|
||||
);
|
||||
const file = new File(
|
||||
[new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])],
|
||||
"favicon.png",
|
||||
{ type: "image/png" },
|
||||
);
|
||||
|
||||
await expect(
|
||||
executeContentExternalMutation("favicon.save", { file }, context),
|
||||
).rejects.toThrow("database offline");
|
||||
const writtenPath = fsMocks.writeFile.mock.calls[0]?.[0];
|
||||
expect(fsMocks.unlink).toHaveBeenCalledWith(writtenPath);
|
||||
});
|
||||
|
||||
it("reports a partial favicon result when database failure compensation also fails", async () => {
|
||||
dbMocks.onDuplicateKeyUpdate.mockRejectedValueOnce(
|
||||
new Error("database offline"),
|
||||
);
|
||||
fsMocks.unlink.mockRejectedValueOnce(new Error("cleanup failed"));
|
||||
const file = new File(
|
||||
[new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])],
|
||||
"favicon.png",
|
||||
{ type: "image/png" },
|
||||
);
|
||||
|
||||
await expect(
|
||||
executeContentExternalMutation("favicon.save", { file }, context),
|
||||
).rejects.toMatchObject({
|
||||
name: "ContentCommittedExternalFailure",
|
||||
snapshot: {
|
||||
before: { value: null },
|
||||
after: { value: null },
|
||||
output: { compensation: "failed" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("removes a newly written logo when the database write fails", async () => {
|
||||
dbMocks.onDuplicateKeyUpdate.mockRejectedValueOnce(
|
||||
new Error("database offline"),
|
||||
);
|
||||
const file = new File(
|
||||
[new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])],
|
||||
"logo.png",
|
||||
{ type: "image/png" },
|
||||
);
|
||||
await expect(
|
||||
executeContentExternalMutation("logo.save", { file }, context),
|
||||
).rejects.toThrow("database offline");
|
||||
expect(fsMocks.unlink).toHaveBeenCalledWith(
|
||||
fsMocks.writeFile.mock.calls[0]?.[0],
|
||||
);
|
||||
});
|
||||
|
||||
it("does not delete the existing favicon before the database delete commits", async () => {
|
||||
siteMocks.get.mockResolvedValueOnce("/api/media/favicon/old.png");
|
||||
dbMocks.where.mockRejectedValueOnce(new Error("database offline"));
|
||||
await expect(
|
||||
executeContentExternalMutation("favicon.delete", {}, context),
|
||||
).rejects.toThrow("database offline");
|
||||
expect(fsMocks.unlink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renames a custom theme without replacing its saved settings with the active site theme", async () => {
|
||||
const storedTheme = {
|
||||
id: "saved-theme",
|
||||
name: "Stored",
|
||||
createdAt: 123,
|
||||
settings: { primary: "#stored", accent: "#saved" },
|
||||
};
|
||||
siteMocks.get.mockImplementation(async (key: string) =>
|
||||
key === "custom_themes" ? JSON.stringify([storedTheme]) : "#active",
|
||||
);
|
||||
|
||||
await executeContentExternalMutation(
|
||||
"theme.custom-change",
|
||||
{ action: "rename", id: storedTheme.id, name: "Renamed" },
|
||||
context,
|
||||
);
|
||||
|
||||
const write = dbMocks.values.mock.calls.find(
|
||||
([value]) => value.key === "custom_themes",
|
||||
)?.[0];
|
||||
const persisted = JSON.parse(String(write?.value));
|
||||
expect(persisted).toEqual([
|
||||
{ ...storedTheme, name: "Renamed", settings: storedTheme.settings },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports typed partial completion when a committed photo delete cannot purge the file", async () => {
|
||||
photoMocks.remove.mockResolvedValueOnce(false);
|
||||
await expect(
|
||||
executeContentExternalMutation("photo.delete", { id: 7 }, context),
|
||||
).rejects.toMatchObject({
|
||||
name: "ContentCommittedExternalFailure",
|
||||
snapshot: {
|
||||
before: { id: 7, url: "/photos/7.png" },
|
||||
after: null,
|
||||
},
|
||||
});
|
||||
expect(dbMocks.where).toHaveBeenCalled();
|
||||
expect(photoMocks.activity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports typed partial completion when photo audit fails after the delete", async () => {
|
||||
photoMocks.activity.mockRejectedValueOnce(new Error("audit offline"));
|
||||
await expect(
|
||||
executeContentExternalMutation("photo.delete", { id: 7 }, context),
|
||||
).rejects.toMatchObject({
|
||||
name: "ContentCommittedExternalFailure",
|
||||
snapshot: {
|
||||
before: { id: 7, url: "/photos/7.png" },
|
||||
after: null,
|
||||
},
|
||||
});
|
||||
expect(photoMocks.remove).toHaveBeenCalledWith("/photos/7.png");
|
||||
});
|
||||
it("marks the closed-enum client translation path as runtime-only for Turbopack", () => {
|
||||
const source = readFileSync(
|
||||
"src/features/housekeeping/domains/content/services/mutation-runtime-external.ts",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(source).toMatch(
|
||||
/path[.]join\(\s*\/[*]turbopackIgnore: true[*]\/\s*process[.]cwd\(\),\s*file[.]relPath/u,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,804 @@
|
||||
import "server-only";
|
||||
|
||||
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import * as JSONC from "jsonc-parser";
|
||||
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
|
||||
import { getClientTranslationFile } from "@/lib/client-translation-files";
|
||||
import { CameraWeb, db, EmulatorSettings, WebsiteSetting } from "@/lib/db";
|
||||
import { patchJson5 } from "@/lib/json5-patch";
|
||||
import { MEDIA_ROOT, resolveMediaPath } from "@/lib/media-storage";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import { ensureReadableThemeColors } from "@/lib/theme-contrast";
|
||||
import {
|
||||
deleteCustomThemeStore,
|
||||
getCustomTheme,
|
||||
snapshotCurrentTheme,
|
||||
upsertCustomTheme,
|
||||
} from "@/lib/theme-custom-store";
|
||||
import { FONTS, PRESETS, THEME_COLOR_KEYS } from "@/lib/theme-presets";
|
||||
import { presetSettings, settingKey } from "@/lib/theme-settings";
|
||||
import {
|
||||
type ContentMutationContext,
|
||||
ContentMutationFailure,
|
||||
type ContentMutationOperation,
|
||||
type ContentMutationSnapshot,
|
||||
} from "./mutations";
|
||||
import { ContentCommittedExternalFailure } from "./mutations-production";
|
||||
|
||||
const MEDIA_TYPES = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
] as const;
|
||||
const FAVICON_TYPES = [
|
||||
...MEDIA_TYPES,
|
||||
"image/x-icon",
|
||||
"image/svg+xml",
|
||||
] as const;
|
||||
const IMAGE_EXTENSIONS = {
|
||||
"image/png": [".png"],
|
||||
"image/jpeg": [".jpg", ".jpeg"],
|
||||
"image/gif": [".gif"],
|
||||
"image/webp": [".webp"],
|
||||
"image/x-icon": [".ico"],
|
||||
"image/svg+xml": [".svg"],
|
||||
} as const;
|
||||
const CMS_LOCALES = new Set([
|
||||
"en",
|
||||
"it",
|
||||
"nl",
|
||||
"de",
|
||||
"fr",
|
||||
"es",
|
||||
"pt",
|
||||
"pl",
|
||||
"sv",
|
||||
"tr",
|
||||
"ro",
|
||||
"hu",
|
||||
"cs",
|
||||
"sk",
|
||||
"da",
|
||||
"no",
|
||||
"el",
|
||||
"bg",
|
||||
"hr",
|
||||
"sr",
|
||||
"uk",
|
||||
"ru",
|
||||
]);
|
||||
const COLOR_RE = /^[#a-zA-Z0-9(),.\s%-]+$/;
|
||||
const ADMIN_COLOR_KEYS = [
|
||||
"admin_canvas",
|
||||
"admin_surface",
|
||||
"admin_text",
|
||||
"admin_text_muted",
|
||||
"admin_border",
|
||||
"admin_sidebar_bg",
|
||||
] as const;
|
||||
const HEADING_KEYS = [
|
||||
"size_heading_h1",
|
||||
"size_heading_h2",
|
||||
"size_heading_h3",
|
||||
] as const;
|
||||
|
||||
function validation(): ContentMutationFailure {
|
||||
return new ContentMutationFailure(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
);
|
||||
}
|
||||
|
||||
function notFound(): ContentMutationFailure {
|
||||
return new ContentMutationFailure(
|
||||
"NOT_FOUND",
|
||||
"errors.housekeeping.notFound",
|
||||
);
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value))
|
||||
throw validation();
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function text(value: unknown, maximum: number, required = false): string {
|
||||
const normalized = String(value ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, maximum);
|
||||
if (required && !normalized) throw validation();
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function jsonRecord(value: unknown): Record<string, unknown> {
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
return record(JSON.parse(value));
|
||||
} catch {
|
||||
throw validation();
|
||||
}
|
||||
}
|
||||
return record(value);
|
||||
}
|
||||
|
||||
function fileValue(value: unknown): File {
|
||||
if (
|
||||
typeof value !== "object" ||
|
||||
value === null ||
|
||||
typeof (value as File).arrayBuffer !== "function" ||
|
||||
typeof (value as File).name !== "string" ||
|
||||
typeof (value as File).type !== "string" ||
|
||||
typeof (value as File).size !== "number"
|
||||
) {
|
||||
throw validation();
|
||||
}
|
||||
return value as File;
|
||||
}
|
||||
|
||||
type SupportedImageType = keyof typeof IMAGE_EXTENSIONS;
|
||||
|
||||
function detectedImageType(bytes: Buffer): SupportedImageType | null {
|
||||
if (
|
||||
bytes.length >= 8 &&
|
||||
bytes
|
||||
.subarray(0, 8)
|
||||
.equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
||||
)
|
||||
return "image/png";
|
||||
if (
|
||||
bytes.length >= 3 &&
|
||||
bytes[0] === 0xff &&
|
||||
bytes[1] === 0xd8 &&
|
||||
bytes[2] === 0xff
|
||||
)
|
||||
return "image/jpeg";
|
||||
const header = bytes.subarray(0, 12).toString("ascii");
|
||||
if (header.startsWith("GIF87a") || header.startsWith("GIF89a"))
|
||||
return "image/gif";
|
||||
if (header.startsWith("RIFF") && header.slice(8, 12) === "WEBP")
|
||||
return "image/webp";
|
||||
if (
|
||||
bytes.length >= 4 &&
|
||||
bytes[0] === 0 &&
|
||||
bytes[1] === 0 &&
|
||||
bytes[2] === 1 &&
|
||||
bytes[3] === 0
|
||||
)
|
||||
return "image/x-icon";
|
||||
const source = bytes
|
||||
.toString("utf8")
|
||||
.replace(/^\uFEFF/u, "")
|
||||
.trimStart();
|
||||
const svg = source.replace(/^<\?xml[^>]*>\s*/iu, "");
|
||||
if (/^<svg(?:\s|>)/iu.test(svg)) return "image/svg+xml";
|
||||
return null;
|
||||
}
|
||||
|
||||
function isSafeSvg(bytes: Buffer): boolean {
|
||||
const source = bytes.toString("utf8");
|
||||
return (
|
||||
!/<(?:script|foreignObject|iframe|object|embed|link|meta)(?:\s|>)/iu.test(
|
||||
source,
|
||||
) &&
|
||||
!/\son[a-z]+\s*=/iu.test(source) &&
|
||||
!/(?:href|src)\s*=\s*["']?\s*(?:javascript:|data:text\/html)/iu.test(source)
|
||||
);
|
||||
}
|
||||
|
||||
async function validatedImage(
|
||||
value: unknown,
|
||||
maximum: number,
|
||||
allowedTypes: readonly SupportedImageType[],
|
||||
): Promise<{
|
||||
file: File;
|
||||
bytes: Buffer;
|
||||
type: SupportedImageType;
|
||||
extension: string;
|
||||
}> {
|
||||
const file = fileValue(value);
|
||||
if (file.size <= 0 || file.size > maximum) throw validation();
|
||||
const bytes = Buffer.from(await file.arrayBuffer());
|
||||
if (bytes.length <= 0 || bytes.length > maximum || bytes.length !== file.size)
|
||||
throw validation();
|
||||
const type = detectedImageType(bytes);
|
||||
if (!type || !allowedTypes.includes(type) || file.type.toLowerCase() !== type)
|
||||
throw validation();
|
||||
if (type === "image/svg+xml" && !isSafeSvg(bytes)) throw validation();
|
||||
const extension = path.extname(file.name).toLowerCase();
|
||||
if (!(IMAGE_EXTENSIONS[type] as readonly string[]).includes(extension))
|
||||
throw validation();
|
||||
return { file, bytes, type, extension: IMAGE_EXTENSIONS[type][0].slice(1) };
|
||||
}
|
||||
|
||||
async function writeWebsiteSetting(
|
||||
key: string,
|
||||
value: string,
|
||||
comment: string,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key, value, comment })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
}
|
||||
|
||||
async function mediaUpload(input: unknown): Promise<ContentMutationSnapshot> {
|
||||
const { file, bytes, type, extension } = await validatedImage(
|
||||
record(input).file,
|
||||
5 * 1024 * 1024,
|
||||
MEDIA_TYPES,
|
||||
);
|
||||
await mkdir(MEDIA_ROOT, { recursive: true });
|
||||
const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${extension}`;
|
||||
const filePath = resolveMediaPath(name);
|
||||
if (!filePath.startsWith(MEDIA_ROOT + path.sep)) throw validation();
|
||||
await writeFile(filePath, bytes);
|
||||
return {
|
||||
before: null,
|
||||
after: { name, size: file.size, type },
|
||||
output: { name, url: `/api/media/${name}` },
|
||||
};
|
||||
}
|
||||
|
||||
async function mediaDelete(input: unknown): Promise<ContentMutationSnapshot> {
|
||||
const name = text(record(input).filename ?? record(input).name, 255, true);
|
||||
if (name.includes("/") || name.includes("\\")) throw validation();
|
||||
let filePath: string;
|
||||
try {
|
||||
filePath = resolveMediaPath(name);
|
||||
} catch {
|
||||
throw validation();
|
||||
}
|
||||
if (!filePath.startsWith(MEDIA_ROOT + path.sep)) throw validation();
|
||||
try {
|
||||
await unlink(filePath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
return { before: { name }, after: null };
|
||||
}
|
||||
|
||||
async function photoDelete(
|
||||
input: unknown,
|
||||
context: ContentMutationContext,
|
||||
): Promise<ContentMutationSnapshot> {
|
||||
const id = Number(record(input).id);
|
||||
if (!Number.isSafeInteger(id) || id <= 0) throw validation();
|
||||
const [row] = await db
|
||||
.select({ id: CameraWeb.id, url: CameraWeb.url })
|
||||
.from(CameraWeb)
|
||||
.where(eq(CameraWeb.id, id))
|
||||
.limit(1);
|
||||
if (!row) throw notFound();
|
||||
await db.delete(CameraWeb).where(eq(CameraWeb.id, id));
|
||||
const snapshot: ContentMutationSnapshot = {
|
||||
before: { id, url: row.url },
|
||||
after: null,
|
||||
};
|
||||
try {
|
||||
const removed = await tryRemoveLocalPhotoFile(row.url);
|
||||
if (!removed) throw new Error("Photo file purge failed");
|
||||
await logStaffActivity({
|
||||
staffId: context.capability.actor.id,
|
||||
action: "photo_delete",
|
||||
description: `Deleted camera photo #${id}`,
|
||||
targetType: "camera_web",
|
||||
targetId: id,
|
||||
});
|
||||
} catch {
|
||||
throw new ContentCommittedExternalFailure(snapshot);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function themeUpdate(
|
||||
input: unknown,
|
||||
context: ContentMutationContext,
|
||||
): Promise<ContentMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const source = jsonRecord(data.values ?? data);
|
||||
const changed: string[] = [];
|
||||
await db.transaction(async (transaction) => {
|
||||
for (const mode of ["light", "dark"] as const) {
|
||||
const bag: Record<string, string> = {};
|
||||
for (const key of THEME_COLOR_KEYS) {
|
||||
const databaseKey = settingKey(key, mode);
|
||||
const raw = text(source[databaseKey], 255);
|
||||
if (raw && COLOR_RE.test(raw)) bag[key] = raw;
|
||||
}
|
||||
for (const [key, value] of Object.entries(
|
||||
ensureReadableThemeColors(bag),
|
||||
)) {
|
||||
const databaseKey = settingKey(
|
||||
key as (typeof THEME_COLOR_KEYS)[number],
|
||||
mode,
|
||||
);
|
||||
await transaction
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key: databaseKey, value, comment: "Theme (housekeeping)" })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
changed.push(databaseKey);
|
||||
}
|
||||
}
|
||||
const adminBag: Record<string, string> = {};
|
||||
for (const key of ADMIN_COLOR_KEYS) {
|
||||
const raw = text(source[key], 255);
|
||||
if (raw && COLOR_RE.test(raw)) adminBag[key] = raw;
|
||||
}
|
||||
for (const [key, value] of Object.entries(
|
||||
ensureReadableThemeColors(adminBag),
|
||||
)) {
|
||||
await transaction
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key, value, comment: "Theme (housekeeping)" })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
changed.push(key);
|
||||
}
|
||||
const radius = text(source.border_radius, 3);
|
||||
if (/^\d{1,3}$/u.test(radius)) {
|
||||
await transaction
|
||||
.insert(WebsiteSetting)
|
||||
.values({
|
||||
key: "border_radius",
|
||||
value: radius,
|
||||
comment: "Theme (housekeeping)",
|
||||
})
|
||||
.onDuplicateKeyUpdate({ set: { value: radius } });
|
||||
changed.push("border_radius");
|
||||
}
|
||||
const font = text(source.font_family, 100);
|
||||
if (font in FONTS) {
|
||||
await transaction
|
||||
.insert(WebsiteSetting)
|
||||
.values({
|
||||
key: "font_family",
|
||||
value: font,
|
||||
comment: "Theme (housekeeping)",
|
||||
})
|
||||
.onDuplicateKeyUpdate({ set: { value: font } });
|
||||
changed.push("font_family");
|
||||
}
|
||||
for (const key of HEADING_KEYS) {
|
||||
const value = text(source[key], 3);
|
||||
if (!/^\d{1,3}$/u.test(value)) continue;
|
||||
await transaction
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key, value, comment: "Theme (housekeeping)" })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
changed.push(key);
|
||||
}
|
||||
if (Object.hasOwn(source, "custom_css")) {
|
||||
const value = String(source.custom_css ?? "")
|
||||
.normalize("NFC")
|
||||
.slice(0, 20_000);
|
||||
await transaction
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key: "custom_css", value, comment: "Theme (housekeeping)" })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
changed.push("custom_css");
|
||||
}
|
||||
});
|
||||
const snapshot: ContentMutationSnapshot = {
|
||||
before: null,
|
||||
after: { changedKeys: changed.sort() },
|
||||
};
|
||||
try {
|
||||
siteSettings.reload();
|
||||
await logStaffActivity({
|
||||
staffId: context.capability.actor.id,
|
||||
action: "theme_update",
|
||||
description: "Updated theme settings",
|
||||
});
|
||||
} catch {
|
||||
throw new ContentCommittedExternalFailure(snapshot);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function themeApplyPreset(
|
||||
input: unknown,
|
||||
context: ContentMutationContext,
|
||||
): Promise<ContentMutationSnapshot> {
|
||||
const name = text(record(input).preset, 100, true);
|
||||
const preset = PRESETS[name];
|
||||
if (!preset) throw validation();
|
||||
await db.transaction(async (transaction) => {
|
||||
for (const [key, value] of presetSettings(preset)) {
|
||||
await transaction
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key, value, comment: "Theme (housekeeping)" })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
}
|
||||
await transaction
|
||||
.insert(WebsiteSetting)
|
||||
.values({
|
||||
key: "theme_preset",
|
||||
value: name,
|
||||
comment: "Theme (housekeeping)",
|
||||
})
|
||||
.onDuplicateKeyUpdate({ set: { value: name } });
|
||||
});
|
||||
const snapshot: ContentMutationSnapshot = {
|
||||
before: null,
|
||||
after: { preset: name },
|
||||
output: { name },
|
||||
};
|
||||
try {
|
||||
siteSettings.reload();
|
||||
await logStaffActivity({
|
||||
staffId: context.capability.actor.id,
|
||||
action: "theme_preset",
|
||||
description: `Applied theme preset ${name}`,
|
||||
});
|
||||
} catch {
|
||||
throw new ContentCommittedExternalFailure(snapshot);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function themeCustomChange(
|
||||
input: unknown,
|
||||
context: ContentMutationContext,
|
||||
): Promise<ContentMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const action = text(data.action, 16, true);
|
||||
const id = text(data.id, 100);
|
||||
if (action === "delete") {
|
||||
if (!id) throw validation();
|
||||
const existing = await getCustomTheme(id);
|
||||
if (!existing) throw notFound();
|
||||
await deleteCustomThemeStore(id);
|
||||
return { before: { id, name: existing.name }, after: null };
|
||||
}
|
||||
if (action !== "create" && action !== "update" && action !== "rename")
|
||||
throw validation();
|
||||
const name = text(data.name, 100, true);
|
||||
let settings: Record<string, string>;
|
||||
if (action === "rename") {
|
||||
if (!id) throw validation();
|
||||
const existing = await getCustomTheme(id);
|
||||
if (!existing) throw notFound();
|
||||
settings = existing.settings;
|
||||
} else {
|
||||
settings = data.values
|
||||
? Object.fromEntries(
|
||||
Object.entries(jsonRecord(data.values)).map(([key, value]) => [
|
||||
key,
|
||||
String(value),
|
||||
]),
|
||||
)
|
||||
: await snapshotCurrentTheme();
|
||||
}
|
||||
const theme = await upsertCustomTheme(name, settings, id || undefined);
|
||||
await logStaffActivity({
|
||||
staffId: context.capability.actor.id,
|
||||
action: "theme_preset",
|
||||
description: `Saved custom theme ${theme.name}`,
|
||||
});
|
||||
return {
|
||||
before: id ? { id } : null,
|
||||
after: { id: theme.id, name: theme.name },
|
||||
output: { id: theme.id, name: theme.name },
|
||||
};
|
||||
}
|
||||
|
||||
async function themeApplyCustom(
|
||||
input: unknown,
|
||||
context: ContentMutationContext,
|
||||
): Promise<ContentMutationSnapshot> {
|
||||
const id = text(record(input).id, 100, true);
|
||||
const theme = await getCustomTheme(id);
|
||||
if (!theme) throw notFound();
|
||||
await db.transaction(async (transaction) => {
|
||||
for (const [key, value] of Object.entries(theme.settings)) {
|
||||
if (!value) continue;
|
||||
await transaction
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key, value, comment: "Theme (housekeeping)" })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
}
|
||||
await transaction
|
||||
.insert(WebsiteSetting)
|
||||
.values({
|
||||
key: "theme_preset",
|
||||
value: theme.name,
|
||||
comment: "Theme (housekeeping)",
|
||||
})
|
||||
.onDuplicateKeyUpdate({ set: { value: theme.name } });
|
||||
});
|
||||
const snapshot: ContentMutationSnapshot = {
|
||||
before: null,
|
||||
after: { id, name: theme.name },
|
||||
output: { name: theme.name },
|
||||
};
|
||||
try {
|
||||
siteSettings.reload();
|
||||
await logStaffActivity({
|
||||
staffId: context.capability.actor.id,
|
||||
action: "theme_preset",
|
||||
description: `Applied custom theme ${theme.name}`,
|
||||
});
|
||||
} catch {
|
||||
throw new ContentCommittedExternalFailure(snapshot);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function removeStoredAsset(
|
||||
url: string | null | undefined,
|
||||
directory: string,
|
||||
prefix: string,
|
||||
): Promise<void> {
|
||||
if (!url?.startsWith(prefix)) return;
|
||||
const name = url.slice(prefix.length);
|
||||
if (!name || name.includes("..") || name.includes("/") || name.includes("\\"))
|
||||
return;
|
||||
const filePath = path.resolve(directory, name);
|
||||
if (!filePath.startsWith(directory + path.sep)) return;
|
||||
try {
|
||||
await unlink(filePath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function faviconSave(input: unknown): Promise<ContentMutationSnapshot> {
|
||||
const { bytes, extension } = await validatedImage(
|
||||
record(input).file,
|
||||
2 * 1024 * 1024,
|
||||
FAVICON_TYPES,
|
||||
);
|
||||
const directory = resolveMediaPath("favicon");
|
||||
const filename = `favicon-${Date.now()}.${extension}`;
|
||||
const filePath = path.resolve(directory, filename);
|
||||
if (!filePath.startsWith(directory + path.sep)) throw validation();
|
||||
const oldUrl = await siteSettings.get("cms_favicon");
|
||||
await mkdir(directory, { recursive: true });
|
||||
await writeFile(filePath, bytes);
|
||||
const url = `/api/media/favicon/${filename}`;
|
||||
const snapshot: ContentMutationSnapshot = {
|
||||
before: { value: oldUrl ?? null },
|
||||
after: { value: url },
|
||||
output: { url },
|
||||
};
|
||||
try {
|
||||
await writeWebsiteSetting("cms_favicon", url, "Favicon URL");
|
||||
} catch (error) {
|
||||
try {
|
||||
await unlink(filePath);
|
||||
} catch {
|
||||
throw new ContentCommittedExternalFailure({
|
||||
before: { value: oldUrl ?? null },
|
||||
after: { value: oldUrl ?? null },
|
||||
output: { compensation: "failed" },
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await removeStoredAsset(oldUrl, directory, "/api/media/favicon/");
|
||||
siteSettings.reload();
|
||||
} catch {
|
||||
throw new ContentCommittedExternalFailure(snapshot);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function faviconDelete(): Promise<ContentMutationSnapshot> {
|
||||
const oldUrl = await siteSettings.get("cms_favicon");
|
||||
await db.delete(WebsiteSetting).where(eq(WebsiteSetting.key, "cms_favicon"));
|
||||
const snapshot: ContentMutationSnapshot = {
|
||||
before: { value: oldUrl ?? null },
|
||||
after: null,
|
||||
};
|
||||
try {
|
||||
await removeStoredAsset(
|
||||
oldUrl,
|
||||
resolveMediaPath("favicon"),
|
||||
"/api/media/favicon/",
|
||||
);
|
||||
siteSettings.reload();
|
||||
} catch {
|
||||
throw new ContentCommittedExternalFailure(snapshot);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function logoSave(input: unknown): Promise<ContentMutationSnapshot> {
|
||||
const { bytes, extension } = await validatedImage(
|
||||
record(input).file,
|
||||
5 * 1024 * 1024,
|
||||
MEDIA_TYPES,
|
||||
);
|
||||
const directory = resolveMediaPath("logo");
|
||||
const filename =
|
||||
"logo-" +
|
||||
Date.now() +
|
||||
"-" +
|
||||
Math.random().toString(36).slice(2, 8) +
|
||||
"." +
|
||||
extension;
|
||||
const filePath = path.resolve(directory, filename);
|
||||
if (!filePath.startsWith(directory + path.sep)) throw validation();
|
||||
const oldUrl = await siteSettings.get("cms_logo");
|
||||
await mkdir(directory, { recursive: true });
|
||||
await writeFile(filePath, bytes);
|
||||
const url = `/api/media/logo/${filename}`;
|
||||
const snapshot: ContentMutationSnapshot = {
|
||||
before: { value: oldUrl ?? null },
|
||||
after: { value: url },
|
||||
output: { url },
|
||||
};
|
||||
try {
|
||||
await writeWebsiteSetting("cms_logo", url, "Logo (generator)");
|
||||
} catch (error) {
|
||||
try {
|
||||
await unlink(filePath);
|
||||
} catch {
|
||||
throw new ContentCommittedExternalFailure({
|
||||
before: { value: oldUrl ?? null },
|
||||
after: { value: oldUrl ?? null },
|
||||
output: { compensation: "failed" },
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await removeStoredAsset(oldUrl, directory, "/api/media/logo/");
|
||||
siteSettings.reload();
|
||||
} catch {
|
||||
throw new ContentCommittedExternalFailure(snapshot);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function cmsTranslationSave(
|
||||
input: unknown,
|
||||
): Promise<ContentMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const locale = text(data.locale, 16, true);
|
||||
if (!CMS_LOCALES.has(locale)) throw validation();
|
||||
const translations = jsonRecord(data.data);
|
||||
const filePath = path.join(
|
||||
process.cwd(),
|
||||
"src",
|
||||
"messages",
|
||||
`${locale}.json`,
|
||||
);
|
||||
await writeFile(filePath, JSON.stringify(translations, null, 2), "utf-8");
|
||||
return {
|
||||
before: null,
|
||||
after: { locale, keyCount: Object.keys(translations).length },
|
||||
};
|
||||
}
|
||||
|
||||
async function clientTranslationSave(
|
||||
input: unknown,
|
||||
): Promise<ContentMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const fileId = text(data.fileId, 100, true);
|
||||
const translations = Object.fromEntries(
|
||||
Object.entries(jsonRecord(data.data)).map(([key, value]) => [
|
||||
key,
|
||||
String(value),
|
||||
]),
|
||||
);
|
||||
const file = getClientTranslationFile(fileId);
|
||||
if (!file || file.readOnly) throw validation();
|
||||
// file.relPath comes from CLIENT_TRANSLATION_FILES (a closed enum), but
|
||||
// Turbopack cannot prove that and would otherwise trace the whole project.
|
||||
const absolutePath = path.join(
|
||||
/*turbopackIgnore: true*/ process.cwd(),
|
||||
file.relPath,
|
||||
);
|
||||
const raw = await readFile(absolutePath, "utf-8");
|
||||
if (file.format === "json") {
|
||||
await writeFile(
|
||||
absolutePath,
|
||||
JSON.stringify(translations, null, 4),
|
||||
"utf-8",
|
||||
);
|
||||
return {
|
||||
before: null,
|
||||
after: { fileId, keyCount: Object.keys(translations).length },
|
||||
output: { commentsLost: false, unpatchedKeys: [] },
|
||||
};
|
||||
}
|
||||
const original: Record<string, string> = {};
|
||||
const parsed = JSONC.parse(raw);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
for (const [key, value] of Object.entries(parsed))
|
||||
original[key] = value == null ? "" : String(value);
|
||||
}
|
||||
const patched = patchJson5(raw, original, translations);
|
||||
if (patched.unpatchedKeys.length === 0) {
|
||||
await writeFile(absolutePath, patched.content, "utf-8");
|
||||
} else {
|
||||
await writeFile(
|
||||
absolutePath,
|
||||
JSON.stringify(translations, null, 4),
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
return {
|
||||
before: null,
|
||||
after: { fileId, keyCount: Object.keys(translations).length },
|
||||
output: {
|
||||
commentsLost: patched.unpatchedKeys.length > 0,
|
||||
unpatchedKeys: patched.unpatchedKeys,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function emulatorTranslationSave(
|
||||
input: unknown,
|
||||
context: ContentMutationContext,
|
||||
): Promise<ContentMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const source = data.settings
|
||||
? jsonRecord(data.settings)
|
||||
: data.key
|
||||
? { [text(data.key, 100, true)]: text(data.value, 512) }
|
||||
: jsonRecord(data);
|
||||
const entries = Object.entries(source).map(
|
||||
([key, value]) => [text(key, 100, true), text(value, 512)] as const,
|
||||
);
|
||||
await db.transaction(async (transaction) => {
|
||||
for (const [key, value] of entries) {
|
||||
await transaction
|
||||
.insert(EmulatorSettings)
|
||||
.values({ key, value })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
}
|
||||
});
|
||||
const snapshot: ContentMutationSnapshot = {
|
||||
before: null,
|
||||
after: { keys: entries.map(([key]) => key).sort() },
|
||||
};
|
||||
try {
|
||||
const delivered = await rcon.updateConfig();
|
||||
if (!context.legacy && !delivered)
|
||||
throw new Error("emulator configuration sync failed");
|
||||
} catch {
|
||||
throw new ContentCommittedExternalFailure(snapshot);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
const EXTERNAL_HANDLERS: Partial<
|
||||
Record<
|
||||
ContentMutationOperation,
|
||||
(
|
||||
input: unknown,
|
||||
context: ContentMutationContext,
|
||||
) => Promise<ContentMutationSnapshot>
|
||||
>
|
||||
> = {
|
||||
"media.upload": (input) => mediaUpload(input),
|
||||
"media.delete": (input) => mediaDelete(input),
|
||||
"photo.delete": photoDelete,
|
||||
"theme.update": themeUpdate,
|
||||
"theme.apply-preset": themeApplyPreset,
|
||||
"theme.custom-change": themeCustomChange,
|
||||
"theme.apply-custom": themeApplyCustom,
|
||||
"favicon.save": (input) => faviconSave(input),
|
||||
"favicon.delete": () => faviconDelete(),
|
||||
"logo.save": (input) => logoSave(input),
|
||||
"translation.cms.save": (input) => cmsTranslationSave(input),
|
||||
"translation.client.save": (input) => clientTranslationSave(input),
|
||||
"translation.emulator.save": emulatorTranslationSave,
|
||||
};
|
||||
|
||||
export async function executeContentExternalMutation(
|
||||
operation: ContentMutationOperation,
|
||||
input: unknown,
|
||||
context: ContentMutationContext,
|
||||
): Promise<ContentMutationSnapshot | null> {
|
||||
const handler = EXTERNAL_HANDLERS[operation];
|
||||
return handler ? handler(input, context) : null;
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { AuditEntry } from "@/lib/services/audit";
|
||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||
import { CONTENT_MUTATION_OPERATIONS } from "./mutations";
|
||||
import {
|
||||
CONTENT_DATABASE_OPERATIONS,
|
||||
CONTENT_EXTERNAL_OPERATIONS,
|
||||
CONTENT_MIXED_OPERATIONS,
|
||||
ContentCommittedExternalFailure,
|
||||
createContentProductionMutationAdapter,
|
||||
} from "./mutations-production";
|
||||
|
||||
const capability: HousekeepingCapabilityContext = {
|
||||
actor: { id: 42, username: "operator", rank: 7 },
|
||||
isSuperAdmin: false,
|
||||
has: () => true,
|
||||
hasAny: () => true,
|
||||
hasAll: () => true,
|
||||
};
|
||||
const mutationContext = {
|
||||
capability,
|
||||
correlationId: "production-matrix",
|
||||
legacy: false,
|
||||
};
|
||||
|
||||
function dependencies() {
|
||||
const transactionToken = { transaction: true };
|
||||
const writeAudit = vi.fn(
|
||||
async (_entry: AuditEntry, _transaction?: unknown) => undefined,
|
||||
);
|
||||
const executeOperation = vi.fn(async (operation: string) => ({
|
||||
before: { operation, state: "before" },
|
||||
after: { operation, state: "after" },
|
||||
output: { id: "18446744073709551615" },
|
||||
}));
|
||||
const transaction = vi.fn(async (run) => run(transactionToken));
|
||||
return {
|
||||
transactionToken,
|
||||
writeAudit,
|
||||
executeOperation,
|
||||
transaction,
|
||||
adapter: createContentProductionMutationAdapter({
|
||||
transaction,
|
||||
writeAudit,
|
||||
executeOperation,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("Content production mutation adapter", () => {
|
||||
it("classifies every operation exactly once", () => {
|
||||
const classified = [
|
||||
...CONTENT_DATABASE_OPERATIONS,
|
||||
...CONTENT_EXTERNAL_OPERATIONS,
|
||||
...CONTENT_MIXED_OPERATIONS,
|
||||
];
|
||||
expect([...classified].sort()).toEqual(
|
||||
[...CONTENT_MUTATION_OPERATIONS].sort(),
|
||||
);
|
||||
expect(new Set(classified).size).toBe(classified.length);
|
||||
});
|
||||
|
||||
it("executes every production operation and serializes identifiers", async () => {
|
||||
const deps = dependencies();
|
||||
for (const operation of CONTENT_MUTATION_OPERATIONS) {
|
||||
const snapshot = await deps.adapter.execute(
|
||||
operation,
|
||||
{ action: "update" },
|
||||
mutationContext,
|
||||
);
|
||||
expect(snapshot.output?.id, operation).toBe("18446744073709551615");
|
||||
}
|
||||
expect(deps.executeOperation).toHaveBeenCalledTimes(
|
||||
CONTENT_MUTATION_OPERATIONS.length,
|
||||
);
|
||||
});
|
||||
|
||||
it("commits database mutation and canonical success audit in one transaction", async () => {
|
||||
const deps = dependencies();
|
||||
await deps.adapter.execute(
|
||||
"article.change",
|
||||
{ action: "update", id: "18446744073709551615" },
|
||||
mutationContext,
|
||||
);
|
||||
|
||||
expect(deps.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(deps.executeOperation).toHaveBeenCalledWith(
|
||||
"article.change",
|
||||
expect.anything(),
|
||||
mutationContext,
|
||||
deps.transactionToken,
|
||||
);
|
||||
expect(deps.writeAudit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "content.article.change",
|
||||
outcome: "success",
|
||||
domain: "content",
|
||||
correlationId: "production-matrix",
|
||||
}),
|
||||
deps.transactionToken,
|
||||
);
|
||||
});
|
||||
|
||||
it("persists correlated intent and truthful outcome around external storage writes", async () => {
|
||||
const deps = dependencies();
|
||||
await deps.adapter.execute(
|
||||
"translation.cms.save",
|
||||
{ locale: "en", data: { welcome: "Hello" } },
|
||||
mutationContext,
|
||||
);
|
||||
|
||||
expect(deps.transaction).not.toHaveBeenCalled();
|
||||
expect(deps.writeAudit.mock.calls.map(([entry]) => entry.outcome)).toEqual([
|
||||
"intent",
|
||||
"success",
|
||||
]);
|
||||
expect(
|
||||
deps.writeAudit.mock.calls.every(
|
||||
([entry]) => entry.correlationId === "production-matrix",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(JSON.stringify(deps.writeAudit.mock.calls)).not.toContain("Hello");
|
||||
});
|
||||
|
||||
it.each(["favicon.save", "logo.save"] as const)(
|
||||
"routes %s through correlated intent and outcome audit",
|
||||
async (operation) => {
|
||||
const deps = dependencies();
|
||||
await deps.adapter.execute(
|
||||
operation,
|
||||
{ file: { name: "brand.png" } },
|
||||
mutationContext,
|
||||
);
|
||||
expect(deps.executeOperation).toHaveBeenCalledWith(
|
||||
operation,
|
||||
expect.anything(),
|
||||
mutationContext,
|
||||
);
|
||||
expect(deps.writeAudit.mock.calls.map(([entry]) => entry)).toEqual([
|
||||
expect.objectContaining({
|
||||
action: `content.${operation}`,
|
||||
outcome: "intent",
|
||||
correlationId: "production-matrix",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
action: `content.${operation}`,
|
||||
outcome: "success",
|
||||
correlationId: "production-matrix",
|
||||
}),
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["favicon.save", "logo.save"] as const)(
|
||||
"returns a typed partial for %s when its outcome audit is unavailable",
|
||||
async (operation) => {
|
||||
const deps = dependencies();
|
||||
deps.writeAudit
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error("audit offline"));
|
||||
const snapshot = await deps.adapter.execute(
|
||||
operation,
|
||||
{ file: { name: "brand.png" } },
|
||||
mutationContext,
|
||||
);
|
||||
expect(snapshot.completion).toEqual({
|
||||
status: "partial",
|
||||
external: "completed",
|
||||
audit: "unavailable",
|
||||
});
|
||||
expect(deps.writeAudit.mock.calls).toEqual([
|
||||
[
|
||||
expect.objectContaining({
|
||||
action: `content.${operation}`,
|
||||
outcome: "intent",
|
||||
}),
|
||||
],
|
||||
[
|
||||
expect.objectContaining({
|
||||
action: `content.${operation}`,
|
||||
outcome: "success",
|
||||
}),
|
||||
],
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("returns typed partial when database committed but the external effect failed", async () => {
|
||||
const deps = dependencies();
|
||||
deps.executeOperation.mockRejectedValueOnce(
|
||||
new ContentCommittedExternalFailure({
|
||||
before: { value: "/old.ico" },
|
||||
after: { value: "/new.ico" },
|
||||
}),
|
||||
);
|
||||
const snapshot = await deps.adapter.execute(
|
||||
"favicon.save",
|
||||
{ file: { name: "favicon.ico" } },
|
||||
mutationContext,
|
||||
);
|
||||
|
||||
expect(snapshot).toMatchObject({
|
||||
before: { value: "/old.ico" },
|
||||
after: { value: "/new.ico" },
|
||||
completion: {
|
||||
status: "partial",
|
||||
external: "failed",
|
||||
audit: "persisted",
|
||||
},
|
||||
});
|
||||
expect(deps.writeAudit.mock.calls.at(-1)?.[0]).toMatchObject({
|
||||
outcome: "partial",
|
||||
});
|
||||
});
|
||||
|
||||
it("records failure without optimistic after-state when external storage fails before commit", async () => {
|
||||
const deps = dependencies();
|
||||
deps.executeOperation.mockRejectedValueOnce(new Error("disk offline"));
|
||||
await expect(
|
||||
deps.adapter.execute(
|
||||
"media.upload",
|
||||
{ file: { name: "image.png" } },
|
||||
mutationContext,
|
||||
),
|
||||
).rejects.toThrow("disk offline");
|
||||
expect(deps.writeAudit.mock.calls.at(-1)?.[0]).toMatchObject({
|
||||
outcome: "failure",
|
||||
before: undefined,
|
||||
after: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import type { AuditEntry } from "@/lib/services/audit";
|
||||
import type {
|
||||
ContentMutationAdapter,
|
||||
ContentMutationContext,
|
||||
ContentMutationOperation,
|
||||
ContentMutationSnapshot,
|
||||
} from "./mutations";
|
||||
|
||||
export const CONTENT_DATABASE_OPERATIONS = [
|
||||
"article.change",
|
||||
"ad.change",
|
||||
"banner.change",
|
||||
"event-type.change",
|
||||
"event.change",
|
||||
"event-prize.change",
|
||||
"event-winner.add",
|
||||
"poll.change",
|
||||
"poll-question.change",
|
||||
"tag.change",
|
||||
"prefix.change",
|
||||
"prefix-blacklist.change",
|
||||
"prefix-settings.update",
|
||||
"help-question.change",
|
||||
"writeable-box.change",
|
||||
"email-template.change",
|
||||
] as const satisfies readonly ContentMutationOperation[];
|
||||
|
||||
export const CONTENT_EXTERNAL_OPERATIONS = [
|
||||
"media.upload",
|
||||
"media.delete",
|
||||
"translation.cms.save",
|
||||
"translation.client.save",
|
||||
] as const satisfies readonly ContentMutationOperation[];
|
||||
|
||||
export const CONTENT_MIXED_OPERATIONS = [
|
||||
"photo.delete",
|
||||
"theme.update",
|
||||
"theme.apply-preset",
|
||||
"theme.custom-change",
|
||||
"theme.apply-custom",
|
||||
"favicon.save",
|
||||
"favicon.delete",
|
||||
"logo.save",
|
||||
"translation.emulator.save",
|
||||
] as const satisfies readonly ContentMutationOperation[];
|
||||
|
||||
type TransactionToken = unknown;
|
||||
|
||||
export interface ContentProductionMutationDependencies {
|
||||
transaction<T>(
|
||||
run: (transaction: TransactionToken) => Promise<T>,
|
||||
): Promise<T>;
|
||||
writeAudit(entry: AuditEntry, transaction?: TransactionToken): Promise<void>;
|
||||
executeOperation(
|
||||
operation: ContentMutationOperation,
|
||||
input: unknown,
|
||||
context: ContentMutationContext,
|
||||
transaction?: TransactionToken,
|
||||
): Promise<ContentMutationSnapshot>;
|
||||
}
|
||||
|
||||
export class ContentCommittedExternalFailure extends Error {
|
||||
constructor(readonly snapshot: ContentMutationSnapshot) {
|
||||
super("Content database change committed but external effect failed");
|
||||
this.name = "ContentCommittedExternalFailure";
|
||||
}
|
||||
}
|
||||
|
||||
function auditEntry(
|
||||
operation: ContentMutationOperation,
|
||||
context: ContentMutationContext,
|
||||
outcome: NonNullable<AuditEntry["outcome"]>,
|
||||
snapshot?: ContentMutationSnapshot,
|
||||
): AuditEntry {
|
||||
return {
|
||||
userId: context.capability.actor.id,
|
||||
action: `content.${operation}`,
|
||||
target: "Content",
|
||||
correlationId: context.correlationId,
|
||||
domain: "content",
|
||||
outcome,
|
||||
before:
|
||||
snapshot?.before === null || snapshot?.before === undefined
|
||||
? undefined
|
||||
: { ...snapshot.before },
|
||||
after:
|
||||
snapshot?.after === null || snapshot?.after === undefined
|
||||
? undefined
|
||||
: { ...snapshot.after },
|
||||
};
|
||||
}
|
||||
|
||||
function includesOperation(
|
||||
operations: readonly ContentMutationOperation[],
|
||||
operation: ContentMutationOperation,
|
||||
): boolean {
|
||||
return operations.includes(operation);
|
||||
}
|
||||
|
||||
async function writeOutcomeOrMarkUnavailable(
|
||||
dependencies: ContentProductionMutationDependencies,
|
||||
operation: ContentMutationOperation,
|
||||
context: ContentMutationContext,
|
||||
snapshot: ContentMutationSnapshot,
|
||||
outcome: "success" | "partial",
|
||||
): Promise<ContentMutationSnapshot> {
|
||||
try {
|
||||
await dependencies.writeAudit(
|
||||
auditEntry(operation, context, outcome, snapshot),
|
||||
);
|
||||
return snapshot;
|
||||
} catch {
|
||||
const completion = {
|
||||
status: "partial" as const,
|
||||
external:
|
||||
outcome === "partial" ? ("failed" as const) : ("completed" as const),
|
||||
audit: "unavailable" as const,
|
||||
};
|
||||
return { ...snapshot, completion };
|
||||
}
|
||||
}
|
||||
|
||||
export function createContentProductionMutationAdapter(
|
||||
dependencies: ContentProductionMutationDependencies,
|
||||
): ContentMutationAdapter {
|
||||
return {
|
||||
async execute(operation, input, context) {
|
||||
if (includesOperation(CONTENT_DATABASE_OPERATIONS, operation)) {
|
||||
return dependencies.transaction(async (transaction) => {
|
||||
const snapshot = await dependencies.executeOperation(
|
||||
operation,
|
||||
input,
|
||||
context,
|
||||
transaction,
|
||||
);
|
||||
await dependencies.writeAudit(
|
||||
auditEntry(operation, context, "success", snapshot),
|
||||
transaction,
|
||||
);
|
||||
return snapshot;
|
||||
});
|
||||
}
|
||||
|
||||
await dependencies.writeAudit(auditEntry(operation, context, "intent"));
|
||||
try {
|
||||
const snapshot = await dependencies.executeOperation(
|
||||
operation,
|
||||
input,
|
||||
context,
|
||||
);
|
||||
return writeOutcomeOrMarkUnavailable(
|
||||
dependencies,
|
||||
operation,
|
||||
context,
|
||||
snapshot,
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
includesOperation(CONTENT_MIXED_OPERATIONS, operation) &&
|
||||
error instanceof ContentCommittedExternalFailure
|
||||
) {
|
||||
const snapshot: ContentMutationSnapshot = {
|
||||
...error.snapshot,
|
||||
completion: {
|
||||
status: "partial",
|
||||
external: "failed",
|
||||
audit: "persisted",
|
||||
},
|
||||
};
|
||||
return writeOutcomeOrMarkUnavailable(
|
||||
dependencies,
|
||||
operation,
|
||||
context,
|
||||
snapshot,
|
||||
"partial",
|
||||
);
|
||||
}
|
||||
try {
|
||||
await dependencies.writeAudit(
|
||||
auditEntry(operation, context, "failure"),
|
||||
);
|
||||
} catch {
|
||||
// Preserve the original dependency failure; the missing outcome is observable
|
||||
// through the durable correlated intent.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const contentProductionMutationAdapter =
|
||||
createContentProductionMutationAdapter({
|
||||
async transaction(run) {
|
||||
const { db } = await import("@/lib/db");
|
||||
return db.transaction((transaction) => run(transaction));
|
||||
},
|
||||
async writeAudit(entry, transaction) {
|
||||
const { logAudit } = await import("@/lib/services/audit");
|
||||
await logAudit(entry, transaction as never);
|
||||
},
|
||||
async executeOperation(operation, input, context, transaction) {
|
||||
const { executeContentMutationOperation } = await import(
|
||||
"./mutations-runtime"
|
||||
);
|
||||
return executeContentMutationOperation(
|
||||
operation,
|
||||
input,
|
||||
context,
|
||||
transaction,
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import "server-only";
|
||||
|
||||
import { executeContentDatabaseMutation } from "./mutation-runtime-database";
|
||||
import { executeContentExternalMutation } from "./mutation-runtime-external";
|
||||
import type {
|
||||
ContentMutationContext,
|
||||
ContentMutationOperation,
|
||||
ContentMutationSnapshot,
|
||||
} from "./mutations";
|
||||
import { ContentMutationFailure } from "./mutations";
|
||||
|
||||
export async function executeContentMutationOperation(
|
||||
operation: ContentMutationOperation,
|
||||
input: unknown,
|
||||
context: ContentMutationContext,
|
||||
transaction?: unknown,
|
||||
): Promise<ContentMutationSnapshot> {
|
||||
const databaseResult = await executeContentDatabaseMutation(
|
||||
operation,
|
||||
input,
|
||||
context,
|
||||
transaction,
|
||||
);
|
||||
if (databaseResult) return databaseResult;
|
||||
const externalResult = await executeContentExternalMutation(
|
||||
operation,
|
||||
input,
|
||||
context,
|
||||
);
|
||||
if (externalResult) return externalResult;
|
||||
throw new ContentMutationFailure(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||
import {
|
||||
CONTENT_MUTATION_OPERATIONS,
|
||||
createContentMutationInvocation,
|
||||
createContentMutationService,
|
||||
} from "./mutations";
|
||||
|
||||
function context(
|
||||
granted: readonly string[],
|
||||
actorId = 42,
|
||||
): HousekeepingCapabilityContext {
|
||||
const permissions = new Set(granted);
|
||||
return {
|
||||
actor: { id: actorId, username: "operator", rank: 7 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => permissions.has(slug),
|
||||
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
||||
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
||||
};
|
||||
}
|
||||
|
||||
describe("Content mutation service authority", () => {
|
||||
it("rehydrates server authority and rejects forged actor identity", async () => {
|
||||
const adapter = { execute: vi.fn() };
|
||||
const service = createContentMutationService(adapter, async () =>
|
||||
context([PERMS.NEWS_EDIT], 42),
|
||||
);
|
||||
|
||||
const result = await service.execute(
|
||||
{ correlationId: "forged", expectedActorId: 7 },
|
||||
"article.change",
|
||||
{ action: "create", title: "Forged" },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "FORBIDDEN" },
|
||||
});
|
||||
expect(adapter.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires the exact operation ACL even after dispatcher authorization", async () => {
|
||||
const adapter = { execute: vi.fn() };
|
||||
const service = createContentMutationService(adapter, async () =>
|
||||
context([PERMS.NEWS_EDIT]),
|
||||
);
|
||||
|
||||
const result = await service.execute(
|
||||
{ correlationId: "brand", expectedActorId: 42 },
|
||||
"theme.update",
|
||||
{},
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "FORBIDDEN" },
|
||||
});
|
||||
expect(adapter.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("executes every declared operation through the real service boundary", async () => {
|
||||
const execute = vi.fn(async (_operation, _input, mutationContext) => ({
|
||||
before: null,
|
||||
after: { actorId: mutationContext.capability.actor.id },
|
||||
}));
|
||||
const service = createContentMutationService({ execute }, async () =>
|
||||
context(Object.values(PERMS)),
|
||||
);
|
||||
const invocation = createContentMutationInvocation(
|
||||
{ id: 42 },
|
||||
"operation-matrix",
|
||||
);
|
||||
|
||||
for (const operation of CONTENT_MUTATION_OPERATIONS) {
|
||||
const result = await service.execute(invocation, operation, {});
|
||||
expect(result.ok, operation).toBe(true);
|
||||
}
|
||||
expect(execute).toHaveBeenCalledTimes(CONTENT_MUTATION_OPERATIONS.length);
|
||||
});
|
||||
|
||||
it("maps adapter failures without exposing storage or template payloads", async () => {
|
||||
const service = createContentMutationService(
|
||||
{
|
||||
execute: async () => {
|
||||
throw new Error("C:\\private\\template.json: secret body");
|
||||
},
|
||||
},
|
||||
async () => context([PERMS.SETTINGS_EDIT]),
|
||||
);
|
||||
const result = await service.execute(
|
||||
{ correlationId: "redacted", expectedActorId: 42 },
|
||||
"translation.cms.save",
|
||||
{ data: { secret: "body" } },
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "DEPENDENCY_UNAVAILABLE",
|
||||
messageKey: "errors.housekeeping.dependencyUnavailable",
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain("secret body");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { satisfiesCapability } from "../../../foundation/capability-context";
|
||||
import {
|
||||
anyCapability,
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
type HousekeepingErrorCode,
|
||||
type HousekeepingPartialCompletion,
|
||||
type HousekeepingResult,
|
||||
ok,
|
||||
} from "../../../foundation/contracts";
|
||||
|
||||
export const CONTENT_MUTATION_OPERATIONS = [
|
||||
"article.change",
|
||||
"ad.change",
|
||||
"banner.change",
|
||||
"event-type.change",
|
||||
"event.change",
|
||||
"event-prize.change",
|
||||
"event-winner.add",
|
||||
"poll.change",
|
||||
"poll-question.change",
|
||||
"photo.delete",
|
||||
"media.upload",
|
||||
"media.delete",
|
||||
"tag.change",
|
||||
"prefix.change",
|
||||
"prefix-blacklist.change",
|
||||
"prefix-settings.update",
|
||||
"help-question.change",
|
||||
"writeable-box.change",
|
||||
"email-template.change",
|
||||
"theme.update",
|
||||
"theme.apply-preset",
|
||||
"theme.custom-change",
|
||||
"theme.apply-custom",
|
||||
"favicon.save",
|
||||
"favicon.delete",
|
||||
"logo.save",
|
||||
"translation.cms.save",
|
||||
"translation.client.save",
|
||||
"translation.emulator.save",
|
||||
] as const;
|
||||
|
||||
export type ContentMutationOperation =
|
||||
(typeof CONTENT_MUTATION_OPERATIONS)[number];
|
||||
|
||||
export interface ContentMutationSnapshot {
|
||||
readonly before: Readonly<Record<string, unknown>> | null;
|
||||
readonly after: Readonly<Record<string, unknown>> | null;
|
||||
readonly output?: Readonly<Record<string, unknown>>;
|
||||
readonly completion?: HousekeepingPartialCompletion;
|
||||
}
|
||||
|
||||
export interface ContentMutationInvocation {
|
||||
readonly correlationId: string;
|
||||
readonly expectedActorId: number;
|
||||
readonly legacy?: boolean;
|
||||
}
|
||||
|
||||
export interface ContentMutationContext {
|
||||
readonly capability: HousekeepingCapabilityContext;
|
||||
readonly correlationId: string;
|
||||
readonly legacy: boolean;
|
||||
}
|
||||
|
||||
export interface ContentMutationAdapter {
|
||||
execute(
|
||||
operation: ContentMutationOperation,
|
||||
input: unknown,
|
||||
context: ContentMutationContext,
|
||||
): Promise<ContentMutationSnapshot>;
|
||||
}
|
||||
|
||||
export interface ContentMutationService {
|
||||
execute(
|
||||
invocation: ContentMutationInvocation,
|
||||
operation: ContentMutationOperation,
|
||||
input: unknown,
|
||||
): Promise<HousekeepingResult<ContentMutationSnapshot>>;
|
||||
}
|
||||
|
||||
export class ContentMutationFailure extends Error {
|
||||
constructor(
|
||||
readonly code: HousekeepingErrorCode,
|
||||
readonly messageKey: string,
|
||||
readonly fieldErrors?: Readonly<Record<string, readonly string[]>>,
|
||||
) {
|
||||
super(messageKey);
|
||||
this.name = "ContentMutationFailure";
|
||||
}
|
||||
}
|
||||
|
||||
const OPERATION_PERMISSION = Object.freeze({
|
||||
"article.change": PERMS.NEWS_EDIT,
|
||||
"ad.change": PERMS.PAGES_EDIT,
|
||||
"banner.change": PERMS.BANNERS_EDIT,
|
||||
"event-type.change": PERMS.EVENTS_EDIT,
|
||||
"event.change": PERMS.EVENTS_EDIT,
|
||||
"event-prize.change": PERMS.EVENTS_EDIT,
|
||||
"event-winner.add": PERMS.EVENTS_EDIT,
|
||||
"poll.change": PERMS.POLLS_EDIT,
|
||||
"poll-question.change": PERMS.POLLS_EDIT,
|
||||
"photo.delete": PERMS.PAGES_EDIT,
|
||||
"media.upload": PERMS.PAGES_EDIT,
|
||||
"media.delete": PERMS.PAGES_EDIT,
|
||||
"tag.change": PERMS.PAGES_EDIT,
|
||||
"prefix.change": PERMS.PREFIXES_EDIT,
|
||||
"prefix-blacklist.change": PERMS.PREFIXES_EDIT,
|
||||
"prefix-settings.update": PERMS.PREFIXES_EDIT,
|
||||
"help-question.change": PERMS.PAGES_EDIT,
|
||||
"writeable-box.change": PERMS.PAGES_EDIT,
|
||||
"email-template.change": PERMS.PAGES_EDIT,
|
||||
"theme.update": PERMS.SETTINGS_EDIT,
|
||||
"theme.apply-preset": PERMS.SETTINGS_EDIT,
|
||||
"theme.custom-change": PERMS.SETTINGS_EDIT,
|
||||
"theme.apply-custom": PERMS.SETTINGS_EDIT,
|
||||
"favicon.save": PERMS.SETTINGS_EDIT,
|
||||
"favicon.delete": PERMS.SETTINGS_EDIT,
|
||||
"logo.save": PERMS.SETTINGS_EDIT,
|
||||
"translation.cms.save": PERMS.SETTINGS_EDIT,
|
||||
"translation.client.save": PERMS.SETTINGS_EDIT,
|
||||
"translation.emulator.save": PERMS.SETTINGS_EDIT,
|
||||
} satisfies Record<ContentMutationOperation, string>);
|
||||
|
||||
export function contentMutationCapability(operation: ContentMutationOperation) {
|
||||
return anyCapability(OPERATION_PERMISSION[operation]);
|
||||
}
|
||||
|
||||
export function createContentMutationService(
|
||||
adapter: ContentMutationAdapter,
|
||||
resolveCapabilityContext: () => Promise<HousekeepingCapabilityContext>,
|
||||
): ContentMutationService {
|
||||
return {
|
||||
async execute(invocation, operation, input) {
|
||||
let capability: HousekeepingCapabilityContext;
|
||||
try {
|
||||
capability = await resolveCapabilityContext();
|
||||
} catch {
|
||||
return fail(
|
||||
"UNAUTHENTICATED",
|
||||
"errors.housekeeping.unauthenticated",
|
||||
invocation.correlationId,
|
||||
);
|
||||
}
|
||||
if (
|
||||
capability.actor.id !== invocation.expectedActorId ||
|
||||
!satisfiesCapability(capability, contentMutationCapability(operation))
|
||||
) {
|
||||
return fail(
|
||||
"FORBIDDEN",
|
||||
"errors.housekeeping.forbidden",
|
||||
invocation.correlationId,
|
||||
);
|
||||
}
|
||||
try {
|
||||
const snapshot = await adapter.execute(operation, input, {
|
||||
capability,
|
||||
correlationId: invocation.correlationId,
|
||||
legacy: invocation.legacy === true,
|
||||
});
|
||||
return ok(snapshot, invocation.correlationId, snapshot.completion);
|
||||
} catch (error) {
|
||||
if (error instanceof ContentMutationFailure) {
|
||||
return fail(
|
||||
error.code,
|
||||
error.messageKey,
|
||||
invocation.correlationId,
|
||||
error.fieldErrors,
|
||||
);
|
||||
}
|
||||
return fail(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
invocation.correlationId,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createContentMutationInvocation(
|
||||
actor: { readonly id: number },
|
||||
correlationId: string,
|
||||
): ContentMutationInvocation {
|
||||
return { correlationId, expectedActorId: actor.id, legacy: true };
|
||||
}
|
||||
|
||||
const productionAdapter: ContentMutationAdapter = {
|
||||
async execute(operation, input, context) {
|
||||
const { contentProductionMutationAdapter } = await import(
|
||||
"./mutations-production"
|
||||
);
|
||||
return contentProductionMutationAdapter.execute(operation, input, context);
|
||||
},
|
||||
};
|
||||
|
||||
export const contentMutationService = createContentMutationService(
|
||||
productionAdapter,
|
||||
async () => {
|
||||
const { getHousekeepingCapabilityContext } = await import(
|
||||
"../../../foundation/server-capability-context"
|
||||
);
|
||||
return getHousekeepingCapabilityContext();
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,54 @@
|
||||
import "server-only";
|
||||
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingCapabilityContext } from "../../foundation/contracts";
|
||||
import { contentQuery } from "./queries/content-queries";
|
||||
|
||||
type ContentWidgetKind = "editorial" | "media" | "localization";
|
||||
|
||||
async function total(
|
||||
context: HousekeepingCapabilityContext,
|
||||
routeId:
|
||||
| "content.editorial.articles"
|
||||
| "content.media.banners"
|
||||
| "content.media.photos"
|
||||
| "content.media.library"
|
||||
| "content.localization.overview",
|
||||
): Promise<number> {
|
||||
const result = await contentQuery.run(context, {
|
||||
routeId,
|
||||
list: { pageSize: 1, offset: 0 },
|
||||
});
|
||||
if (!result.ok) throw new Error("Content widget query unavailable");
|
||||
return result.data.total;
|
||||
}
|
||||
|
||||
export async function loadContentWidget(
|
||||
kind: ContentWidgetKind,
|
||||
context: HousekeepingCapabilityContext,
|
||||
signal: AbortSignal,
|
||||
): Promise<Readonly<Record<string, number>>> {
|
||||
if (signal.aborted) throw new Error("aborted Content widget");
|
||||
if (kind === "editorial") {
|
||||
const articles = await total(context, "content.editorial.articles");
|
||||
return { articles };
|
||||
}
|
||||
if (kind === "media") {
|
||||
const counts: Record<string, number> = {};
|
||||
if (context.has(PERMS.PAGES_VIEW)) {
|
||||
const [photos, library] = await Promise.all([
|
||||
total(context, "content.media.photos"),
|
||||
total(context, "content.media.library"),
|
||||
]);
|
||||
counts.photos = photos;
|
||||
counts.library = library;
|
||||
counts.items = photos + library;
|
||||
}
|
||||
if (context.has(PERMS.BANNERS_VIEW)) {
|
||||
counts.banners = await total(context, "content.media.banners");
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
const stores = await total(context, "content.localization.overview");
|
||||
return { stores };
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { authorizeHousekeeping } from "../../foundation/authorization";
|
||||
import {
|
||||
anyCapability,
|
||||
type CapabilityRequirement,
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
type HousekeepingWidgetDefinition,
|
||||
ok,
|
||||
} from "../../foundation/contracts";
|
||||
|
||||
export const CONTENT_WIDGET_IDS = [
|
||||
"content.editorial-summary",
|
||||
"content.media-summary",
|
||||
"content.localization-summary",
|
||||
] as const;
|
||||
|
||||
type ContentWidgetLoader = (
|
||||
context: HousekeepingCapabilityContext,
|
||||
signal: AbortSignal,
|
||||
) => Promise<Readonly<Record<string, number>>>;
|
||||
|
||||
export interface ContentWidgetAdapters {
|
||||
readonly editorial: ContentWidgetLoader;
|
||||
readonly media: ContentWidgetLoader;
|
||||
readonly localization: ContentWidgetLoader;
|
||||
}
|
||||
|
||||
function createWidget(
|
||||
id: (typeof CONTENT_WIDGET_IDS)[number],
|
||||
kind: "mandatory" | "optional",
|
||||
capability: CapabilityRequirement,
|
||||
load: ContentWidgetLoader,
|
||||
): HousekeepingWidgetDefinition {
|
||||
return {
|
||||
id,
|
||||
owner: "content",
|
||||
kind,
|
||||
capability,
|
||||
async load(context, signal) {
|
||||
const authorization = authorizeHousekeeping(context, capability);
|
||||
if (!authorization.ok) return authorization;
|
||||
try {
|
||||
return ok(await load(context, signal), authorization.correlationId);
|
||||
} catch {
|
||||
return fail(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
authorization.correlationId,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createContentWidgets(
|
||||
adapters: ContentWidgetAdapters,
|
||||
): readonly HousekeepingWidgetDefinition[] {
|
||||
return [
|
||||
createWidget(
|
||||
"content.editorial-summary",
|
||||
"mandatory",
|
||||
anyCapability(PERMS.NEWS_VIEW),
|
||||
adapters.editorial,
|
||||
),
|
||||
createWidget(
|
||||
"content.media-summary",
|
||||
"optional",
|
||||
anyCapability(PERMS.PAGES_VIEW, PERMS.BANNERS_VIEW),
|
||||
adapters.media,
|
||||
),
|
||||
createWidget(
|
||||
"content.localization-summary",
|
||||
"optional",
|
||||
anyCapability(PERMS.SETTINGS_VIEW),
|
||||
adapters.localization,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export const CONTENT_WIDGETS = createContentWidgets({
|
||||
async editorial(context, signal) {
|
||||
const { loadContentWidget } = await import("./widgets-production");
|
||||
return loadContentWidget("editorial", context, signal);
|
||||
},
|
||||
async media(context, signal) {
|
||||
const { loadContentWidget } = await import("./widgets-production");
|
||||
return loadContentWidget("media", context, signal);
|
||||
},
|
||||
async localization(context, signal) {
|
||||
const { loadContentWidget } = await import("./widgets-production");
|
||||
return loadContentWidget("localization", context, signal);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||
import { createEconomyCommands, ECONOMY_COMMAND_IDS } from "./economy-commands";
|
||||
|
||||
const expected = [
|
||||
["economy.catalog.page.change", "catalog-page.change", PERMS.CATALOG_EDIT],
|
||||
["economy.catalog.page.reorder", "catalog-page.reorder", PERMS.CATALOG_EDIT],
|
||||
[
|
||||
"economy.catalog.page.delete-tree",
|
||||
"catalog-page.delete-tree",
|
||||
PERMS.CATALOG_EDIT,
|
||||
],
|
||||
["economy.catalog.bc-page.change", "bc-page.change", PERMS.CATALOG_EDIT],
|
||||
["economy.catalog.bc-page.reorder", "bc-page.reorder", PERMS.CATALOG_EDIT],
|
||||
[
|
||||
"economy.catalog.bc-page.delete-tree",
|
||||
"bc-page.delete-tree",
|
||||
PERMS.CATALOG_EDIT,
|
||||
],
|
||||
["economy.catalog.bc-item.change", "bc-item.change", PERMS.CATALOG_EDIT],
|
||||
["economy.catalog.item.change", "catalog-item.change", PERMS.CATALOG_EDIT],
|
||||
[
|
||||
"economy.catalog.item.bulk-create",
|
||||
"catalog-item.bulk-create",
|
||||
PERMS.CATALOG_EDIT,
|
||||
],
|
||||
["economy.catalog.item.move", "catalog-item.move", PERMS.CATALOG_EDIT],
|
||||
["economy.catalog.item.reorder", "catalog-item.reorder", PERMS.CATALOG_EDIT],
|
||||
[
|
||||
"economy.catalog.item.translate",
|
||||
"catalog-item.translate",
|
||||
PERMS.CATALOG_EDIT,
|
||||
],
|
||||
[
|
||||
"economy.catalog.maintenance.fix-offers",
|
||||
"maintenance.fix-offers",
|
||||
PERMS.CATALOG_EDIT,
|
||||
],
|
||||
[
|
||||
"economy.catalog.maintenance.fix-everything",
|
||||
"maintenance.fix-everything",
|
||||
PERMS.CATALOG_EDIT,
|
||||
],
|
||||
[
|
||||
"economy.catalog.maintenance.fix-sprite-ids",
|
||||
"maintenance.fix-sprite-ids",
|
||||
PERMS.CATALOG_EDIT,
|
||||
],
|
||||
[
|
||||
"economy.catalog.maintenance.reconcile-ids",
|
||||
"maintenance.reconcile-ids",
|
||||
PERMS.CATALOG_EDIT,
|
||||
],
|
||||
[
|
||||
"economy.catalog.maintenance.align-ids",
|
||||
"maintenance.align-ids",
|
||||
PERMS.CATALOG_EDIT,
|
||||
],
|
||||
[
|
||||
"economy.catalog.maintenance.remove-duplicates",
|
||||
"maintenance.remove-duplicates",
|
||||
PERMS.CATALOG_EDIT,
|
||||
],
|
||||
["economy.items.base.update", "items-base.update", PERMS.CATALOG_EDIT],
|
||||
[
|
||||
"economy.commerce.shop-article.change",
|
||||
"shop-article.change",
|
||||
PERMS.SHOP_EDIT,
|
||||
],
|
||||
[
|
||||
"economy.commerce.marketplace.cancel",
|
||||
"marketplace.cancel",
|
||||
PERMS.SHOP_EDIT,
|
||||
],
|
||||
["economy.commerce.voucher.change", "voucher.change", PERMS.SHOP_EDIT],
|
||||
["economy.value.category.change", "rare-category.change", PERMS.SHOP_EDIT],
|
||||
["economy.value.rare.change", "rare-value.change", PERMS.SHOP_EDIT],
|
||||
["economy.rewards.badge.give", "badge.give", PERMS.CATALOG_EDIT],
|
||||
["economy.rewards.badge.upload", "badge.upload", PERMS.CATALOG_EDIT],
|
||||
[
|
||||
"economy.rewards.soundtrack.change",
|
||||
"soundtrack.change",
|
||||
PERMS.CATALOG_EDIT,
|
||||
],
|
||||
] as const;
|
||||
|
||||
function context(): HousekeepingCapabilityContext {
|
||||
return {
|
||||
actor: { id: 42, username: "operator", rank: 7 },
|
||||
isSuperAdmin: false,
|
||||
has: () => true,
|
||||
hasAny: () => true,
|
||||
hasAll: () => true,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Economy commands", () => {
|
||||
it("registers the exact 27-operation matrix with existing ACLs", () => {
|
||||
const commands = createEconomyCommands({ execute: vi.fn() } as never);
|
||||
expect(ECONOMY_COMMAND_IDS).toEqual(expected.map(([id]) => id));
|
||||
expect(
|
||||
commands.map((command) => [
|
||||
command.id,
|
||||
command.operation,
|
||||
command.capability.slugs[0],
|
||||
]),
|
||||
).toEqual(expected);
|
||||
expect(commands.every((command) => command.owner === "economy")).toBe(true);
|
||||
expect(commands.every((command) => command.risk === "sensitive")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("requires a reason for destructive, maintenance, cancellation, and grant operations", () => {
|
||||
const commands = createEconomyCommands({ execute: vi.fn() } as never);
|
||||
const required = commands
|
||||
.filter((command) => command.requiresReason)
|
||||
.map((command) => command.id);
|
||||
expect(required).toEqual([
|
||||
"economy.catalog.page.delete-tree",
|
||||
"economy.catalog.bc-page.delete-tree",
|
||||
"economy.catalog.maintenance.fix-offers",
|
||||
"economy.catalog.maintenance.fix-everything",
|
||||
"economy.catalog.maintenance.fix-sprite-ids",
|
||||
"economy.catalog.maintenance.reconcile-ids",
|
||||
"economy.catalog.maintenance.align-ids",
|
||||
"economy.catalog.maintenance.remove-duplicates",
|
||||
"economy.commerce.marketplace.cancel",
|
||||
"economy.rewards.badge.give",
|
||||
"economy.rewards.badge.upload",
|
||||
]);
|
||||
});
|
||||
|
||||
it("binds real execution to the server-authorized actor", async () => {
|
||||
const execute = vi.fn(async () => ({
|
||||
ok: true as const,
|
||||
data: { before: null, after: { id: "1" } },
|
||||
correlationId: "economy-command",
|
||||
}));
|
||||
const [command] = createEconomyCommands({ execute } as never);
|
||||
await command.execute(
|
||||
{
|
||||
capability: context(),
|
||||
correlationId: "economy-command",
|
||||
ipAddress: "127.0.0.1",
|
||||
},
|
||||
{ action: "update", id: "1", caption: "Seasonal" },
|
||||
);
|
||||
expect(execute).toHaveBeenCalledWith(
|
||||
{ correlationId: "economy-command", expectedActorId: 42 },
|
||||
"catalog-page.change",
|
||||
{ action: "update", id: "1", caption: "Seasonal" },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import "server-only";
|
||||
|
||||
import { z } from "zod";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingCommand } from "../../../foundation/commands/registry";
|
||||
import { anyCapability } from "../../../foundation/contracts";
|
||||
import {
|
||||
type EconomyMutationOperation,
|
||||
type EconomyMutationService,
|
||||
economyMutationService,
|
||||
} from "../services/mutations";
|
||||
|
||||
const ECONOMY_COMMAND_DEFINITIONS = [
|
||||
["economy.catalog.page.change", "catalog-page.change", PERMS.CATALOG_EDIT],
|
||||
["economy.catalog.page.reorder", "catalog-page.reorder", PERMS.CATALOG_EDIT],
|
||||
[
|
||||
"economy.catalog.page.delete-tree",
|
||||
"catalog-page.delete-tree",
|
||||
PERMS.CATALOG_EDIT,
|
||||
],
|
||||
["economy.catalog.bc-page.change", "bc-page.change", PERMS.CATALOG_EDIT],
|
||||
["economy.catalog.bc-page.reorder", "bc-page.reorder", PERMS.CATALOG_EDIT],
|
||||
[
|
||||
"economy.catalog.bc-page.delete-tree",
|
||||
"bc-page.delete-tree",
|
||||
PERMS.CATALOG_EDIT,
|
||||
],
|
||||
["economy.catalog.bc-item.change", "bc-item.change", PERMS.CATALOG_EDIT],
|
||||
["economy.catalog.item.change", "catalog-item.change", PERMS.CATALOG_EDIT],
|
||||
[
|
||||
"economy.catalog.item.bulk-create",
|
||||
"catalog-item.bulk-create",
|
||||
PERMS.CATALOG_EDIT,
|
||||
],
|
||||
["economy.catalog.item.move", "catalog-item.move", PERMS.CATALOG_EDIT],
|
||||
["economy.catalog.item.reorder", "catalog-item.reorder", PERMS.CATALOG_EDIT],
|
||||
[
|
||||
"economy.catalog.item.translate",
|
||||
"catalog-item.translate",
|
||||
PERMS.CATALOG_EDIT,
|
||||
],
|
||||
[
|
||||
"economy.catalog.maintenance.fix-offers",
|
||||
"maintenance.fix-offers",
|
||||
PERMS.CATALOG_EDIT,
|
||||
],
|
||||
[
|
||||
"economy.catalog.maintenance.fix-everything",
|
||||
"maintenance.fix-everything",
|
||||
PERMS.CATALOG_EDIT,
|
||||
],
|
||||
[
|
||||
"economy.catalog.maintenance.fix-sprite-ids",
|
||||
"maintenance.fix-sprite-ids",
|
||||
PERMS.CATALOG_EDIT,
|
||||
],
|
||||
[
|
||||
"economy.catalog.maintenance.reconcile-ids",
|
||||
"maintenance.reconcile-ids",
|
||||
PERMS.CATALOG_EDIT,
|
||||
],
|
||||
[
|
||||
"economy.catalog.maintenance.align-ids",
|
||||
"maintenance.align-ids",
|
||||
PERMS.CATALOG_EDIT,
|
||||
],
|
||||
[
|
||||
"economy.catalog.maintenance.remove-duplicates",
|
||||
"maintenance.remove-duplicates",
|
||||
PERMS.CATALOG_EDIT,
|
||||
],
|
||||
["economy.items.base.update", "items-base.update", PERMS.CATALOG_EDIT],
|
||||
[
|
||||
"economy.commerce.shop-article.change",
|
||||
"shop-article.change",
|
||||
PERMS.SHOP_EDIT,
|
||||
],
|
||||
[
|
||||
"economy.commerce.marketplace.cancel",
|
||||
"marketplace.cancel",
|
||||
PERMS.SHOP_EDIT,
|
||||
],
|
||||
["economy.commerce.voucher.change", "voucher.change", PERMS.SHOP_EDIT],
|
||||
["economy.value.category.change", "rare-category.change", PERMS.SHOP_EDIT],
|
||||
["economy.value.rare.change", "rare-value.change", PERMS.SHOP_EDIT],
|
||||
["economy.rewards.badge.give", "badge.give", PERMS.CATALOG_EDIT],
|
||||
["economy.rewards.badge.upload", "badge.upload", PERMS.CATALOG_EDIT],
|
||||
[
|
||||
"economy.rewards.soundtrack.change",
|
||||
"soundtrack.change",
|
||||
PERMS.CATALOG_EDIT,
|
||||
],
|
||||
] as const;
|
||||
|
||||
export const ECONOMY_COMMAND_IDS = ECONOMY_COMMAND_DEFINITIONS.map(
|
||||
([id]) => id,
|
||||
) as ReadonlyArray<(typeof ECONOMY_COMMAND_DEFINITIONS)[number][0]>;
|
||||
|
||||
const REASON_COMMANDS = new Set<string>([
|
||||
"economy.catalog.page.delete-tree",
|
||||
"economy.catalog.bc-page.delete-tree",
|
||||
"economy.catalog.maintenance.fix-offers",
|
||||
"economy.catalog.maintenance.fix-everything",
|
||||
"economy.catalog.maintenance.fix-sprite-ids",
|
||||
"economy.catalog.maintenance.reconcile-ids",
|
||||
"economy.catalog.maintenance.align-ids",
|
||||
"economy.catalog.maintenance.remove-duplicates",
|
||||
"economy.commerce.marketplace.cancel",
|
||||
"economy.rewards.badge.give",
|
||||
"economy.rewards.badge.upload",
|
||||
]);
|
||||
|
||||
type EconomyCommand = HousekeepingCommand<Record<string, unknown>, unknown> & {
|
||||
readonly operation: EconomyMutationOperation;
|
||||
};
|
||||
|
||||
const commandInput = z.object({}).catchall(z.unknown());
|
||||
|
||||
export function createEconomyCommands(
|
||||
service: Pick<EconomyMutationService, "execute">,
|
||||
): readonly EconomyCommand[] {
|
||||
return ECONOMY_COMMAND_DEFINITIONS.map(
|
||||
([id, operation, permission]): EconomyCommand => ({
|
||||
id,
|
||||
owner: "economy",
|
||||
operation,
|
||||
risk: "sensitive",
|
||||
capability: anyCapability(permission),
|
||||
input: commandInput,
|
||||
requiresReason: REASON_COMMANDS.has(id),
|
||||
rateLimit: { attempts: 10, windowMs: 60_000 },
|
||||
execute: (context, input) =>
|
||||
service.execute(
|
||||
{
|
||||
correlationId: context.correlationId,
|
||||
expectedActorId: context.capability.actor.id,
|
||||
},
|
||||
operation,
|
||||
input,
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export const ECONOMY_COMMANDS = createEconomyCommands(economyMutationService);
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingCapabilityContext } from "../../foundation/contracts";
|
||||
import { fail, ok } from "../../foundation/contracts";
|
||||
import { loadEconomyInboxItems } from "./inbox-production";
|
||||
import { loadEconomySearchCandidates } from "./search-production";
|
||||
import { loadEconomyWidget } from "./widgets-production";
|
||||
|
||||
const context: HousekeepingCapabilityContext = {
|
||||
actor: { id: 42, username: "operator", rank: 7 },
|
||||
isSuperAdmin: false,
|
||||
has: () => true,
|
||||
hasAny: () => true,
|
||||
hasAll: () => true,
|
||||
};
|
||||
|
||||
function queryResult(
|
||||
items: readonly {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
updatedAt?: string | null;
|
||||
href?: `/ase-next/${string}`;
|
||||
}[],
|
||||
total = items.length,
|
||||
) {
|
||||
return ok(
|
||||
{
|
||||
kind: "catalog" as const,
|
||||
items,
|
||||
total,
|
||||
partialDependencies: [],
|
||||
},
|
||||
"economy-provider-production",
|
||||
);
|
||||
}
|
||||
|
||||
describe("Economy production providers", () => {
|
||||
it("routes search through the bounded production query", async () => {
|
||||
const run = vi.fn().mockResolvedValue(
|
||||
queryResult([
|
||||
{
|
||||
id: "7",
|
||||
title: "Root catalog",
|
||||
description: "parent -1",
|
||||
href: "/ase-next/economy/catalog/7",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const candidates = await loadEconomySearchCandidates(
|
||||
"catalog-pages",
|
||||
context,
|
||||
"root",
|
||||
25,
|
||||
{ run },
|
||||
);
|
||||
expect(run).toHaveBeenCalledWith(context, {
|
||||
routeId: "economy.catalog.overview",
|
||||
list: { search: "root", pageSize: 25, offset: 0 },
|
||||
});
|
||||
expect(candidates[0]).toMatchObject({
|
||||
id: "economy.catalog.overview:7",
|
||||
href: "/ase-next/economy/catalog/7",
|
||||
capability: { mode: "any", slugs: [PERMS.CATALOG_VIEW] },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns truthful widget totals and never invents zero on failure", async () => {
|
||||
const run = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(queryResult([], 12))
|
||||
.mockResolvedValueOnce(queryResult([], 340));
|
||||
await expect(
|
||||
loadEconomyWidget("catalog", context, new AbortController().signal, {
|
||||
run,
|
||||
}),
|
||||
).resolves.toEqual({ pages: 12, items: 340 });
|
||||
const unavailable = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
fail(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
"offline",
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
loadEconomyWidget("value", context, new AbortController().signal, {
|
||||
run: unavailable,
|
||||
}),
|
||||
).rejects.toThrow("Economy widget query unavailable");
|
||||
});
|
||||
|
||||
it("emits only actionable commerce anomalies", async () => {
|
||||
const run = vi.fn().mockResolvedValue(
|
||||
queryResult([
|
||||
{
|
||||
id: "1",
|
||||
title: "Pending order",
|
||||
status: "pending",
|
||||
updatedAt: "2026-08-30T10:00:00.000Z",
|
||||
href: "/ase-next/economy/history/transactions",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "Completed order",
|
||||
status: "completed",
|
||||
href: "/ase-next/economy/history/transactions",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const items = await loadEconomyInboxItems(
|
||||
"commerce",
|
||||
context,
|
||||
new AbortController().signal,
|
||||
{ run },
|
||||
);
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toMatchObject({
|
||||
sourceId: "economy.commerce-anomalies",
|
||||
itemId: "1",
|
||||
state: "pending",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import {
|
||||
anyCapability,
|
||||
type HousekeepingCapabilityContext,
|
||||
} from "../../foundation/contracts";
|
||||
import { createEconomyInboxSources, ECONOMY_INBOX_SOURCE_IDS } from "./inbox";
|
||||
import {
|
||||
createEconomySearchProviders,
|
||||
ECONOMY_SEARCH_PROVIDER_IDS,
|
||||
} from "./search";
|
||||
import { createEconomyWidgets, ECONOMY_WIDGET_IDS } from "./widgets";
|
||||
|
||||
function context(granted: readonly string[]): HousekeepingCapabilityContext {
|
||||
const permissions = new Set(granted);
|
||||
return {
|
||||
actor: { id: 42, username: "operator", rank: 7 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => permissions.has(slug),
|
||||
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
||||
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
||||
};
|
||||
}
|
||||
|
||||
describe("Economy providers", () => {
|
||||
it("registers exact bounded catalog, item, and transaction search providers", async () => {
|
||||
const candidates = Array.from({ length: 30 }, (_, index) => ({
|
||||
id: `item-${index}`,
|
||||
title: `Item ${index}`,
|
||||
href: `/ase-next/economy/items/${index + 1}`,
|
||||
capability: anyCapability(PERMS.CATALOG_VIEW),
|
||||
}));
|
||||
const adapters = {
|
||||
catalogPages: vi.fn(async () => []),
|
||||
items: vi.fn(async () => candidates),
|
||||
transactions: vi.fn(async () => []),
|
||||
};
|
||||
const providers = createEconomySearchProviders(adapters);
|
||||
expect(ECONOMY_SEARCH_PROVIDER_IDS).toEqual([
|
||||
"economy.catalog-pages",
|
||||
"economy.items",
|
||||
"economy.transactions",
|
||||
]);
|
||||
expect(providers.map((provider) => provider.id)).toEqual(
|
||||
ECONOMY_SEARCH_PROVIDER_IDS,
|
||||
);
|
||||
const result = await providers[1].search(context([PERMS.CATALOG_VIEW]), {
|
||||
term: " sofa ",
|
||||
limit: 999,
|
||||
});
|
||||
expect(adapters.items).toHaveBeenCalledWith(expect.anything(), "sofa", 25);
|
||||
expect(result).toMatchObject({ ok: true });
|
||||
if (result.ok) expect(result.data).toHaveLength(25);
|
||||
});
|
||||
|
||||
it("exposes capability-selective catalog and commerce anomaly sources", async () => {
|
||||
const catalog = vi.fn(async () => []);
|
||||
const commerce = vi.fn(async () => []);
|
||||
const sources = createEconomyInboxSources({ catalog, commerce });
|
||||
expect(ECONOMY_INBOX_SOURCE_IDS).toEqual([
|
||||
"economy.catalog-attention",
|
||||
"economy.commerce-anomalies",
|
||||
]);
|
||||
await sources[0].getItems(
|
||||
context([PERMS.CATALOG_VIEW]),
|
||||
new AbortController().signal,
|
||||
);
|
||||
const forbidden = await sources[1].getItems(
|
||||
context([PERMS.CATALOG_VIEW]),
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(catalog).toHaveBeenCalledOnce();
|
||||
expect(commerce).not.toHaveBeenCalled();
|
||||
expect(forbidden).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "FORBIDDEN" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps catalog mandatory and commerce/value widgets optional", async () => {
|
||||
const widgets = createEconomyWidgets({
|
||||
catalog: vi.fn(async () => ({ pages: 12, items: 40 })),
|
||||
commerce: vi.fn(async () => ({ orders: 4 })),
|
||||
value: vi.fn(async () => ({ rares: 9 })),
|
||||
});
|
||||
expect(ECONOMY_WIDGET_IDS).toEqual([
|
||||
"economy.catalog-summary",
|
||||
"economy.commerce-summary",
|
||||
"economy.value-summary",
|
||||
]);
|
||||
expect(widgets.map((widget) => widget.kind)).toEqual([
|
||||
"mandatory",
|
||||
"optional",
|
||||
"optional",
|
||||
]);
|
||||
expect(
|
||||
await widgets[0].load(
|
||||
context([PERMS.CATALOG_VIEW]),
|
||||
new AbortController().signal,
|
||||
),
|
||||
).toMatchObject({ ok: true, data: { pages: 12, items: 40 } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import "server-only";
|
||||
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import {
|
||||
anyCapability,
|
||||
type HousekeepingCapabilityContext,
|
||||
type HousekeepingWorkItem,
|
||||
} from "../../foundation/contracts";
|
||||
import { economyQuery } from "./queries/catalog";
|
||||
|
||||
type EconomyInboxKind = "catalog" | "commerce";
|
||||
|
||||
function age(value: string | null | undefined, fallback: number) {
|
||||
const parsed = value ? Date.parse(value) : fallback;
|
||||
const timestamp = Number.isFinite(parsed) ? parsed : fallback;
|
||||
const ageMs = Math.max(0, Date.now() - timestamp);
|
||||
return {
|
||||
occurredAt: new Date(timestamp).toISOString(),
|
||||
ageMs,
|
||||
freshness: ageMs > 86_400_000 ? ("stale" as const) : ("fresh" as const),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadEconomyInboxItems(
|
||||
kind: EconomyInboxKind,
|
||||
context: HousekeepingCapabilityContext,
|
||||
signal: AbortSignal,
|
||||
query: Pick<typeof economyQuery, "run"> = economyQuery,
|
||||
): Promise<readonly HousekeepingWorkItem[]> {
|
||||
if (signal.aborted) throw new Error("aborted Economy inbox");
|
||||
const routeId =
|
||||
kind === "catalog"
|
||||
? ("economy.catalog.overview" as const)
|
||||
: ("economy.history.transactions" as const);
|
||||
const result = await query.run(context, {
|
||||
routeId,
|
||||
list: { pageSize: 25, offset: 0 },
|
||||
});
|
||||
if (!result.ok) throw new Error("Economy inbox query unavailable");
|
||||
const observedAt = Date.now();
|
||||
const actionable =
|
||||
kind === "catalog"
|
||||
? new Set(["hidden"])
|
||||
: new Set([
|
||||
"failed",
|
||||
"denied",
|
||||
"refunded",
|
||||
"cancelled",
|
||||
"canceled",
|
||||
"pending",
|
||||
]);
|
||||
return result.data.items.flatMap((item) => {
|
||||
const status = item.status?.toLocaleLowerCase() ?? "";
|
||||
if (!actionable.has(status) || !item.href) return [];
|
||||
const sourceId =
|
||||
kind === "catalog"
|
||||
? "economy.catalog-attention"
|
||||
: "economy.commerce-anomalies";
|
||||
const permission =
|
||||
kind === "catalog" ? PERMS.CATALOG_VIEW : PERMS.SHOP_VIEW;
|
||||
return [
|
||||
{
|
||||
sourceId,
|
||||
itemId: item.id,
|
||||
deduplicationKey: `${sourceId}:${routeId}:${item.id}`,
|
||||
domain: "economy" as const,
|
||||
capability: anyCapability(permission),
|
||||
severity:
|
||||
status === "pending" ? ("info" as const) : ("warning" as const),
|
||||
priority:
|
||||
status === "pending" ? ("normal" as const) : ("high" as const),
|
||||
...age(item.updatedAt, observedAt),
|
||||
state: status,
|
||||
titleKey: "pages.housekeeping.items.economy",
|
||||
context: { title: item.title },
|
||||
href: item.href as `/ase-next/${string}`,
|
||||
actions: [],
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { authorizeHousekeeping } from "../../foundation/authorization";
|
||||
import { satisfiesCapability } from "../../foundation/capability-context";
|
||||
import {
|
||||
anyCapability,
|
||||
type CapabilityRequirement,
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
type HousekeepingInboxSource,
|
||||
type HousekeepingWorkItem,
|
||||
ok,
|
||||
} from "../../foundation/contracts";
|
||||
import { isSafeHousekeepingHref } from "../../foundation/housekeeping-href";
|
||||
|
||||
export const ECONOMY_INBOX_SOURCE_IDS = [
|
||||
"economy.catalog-attention",
|
||||
"economy.commerce-anomalies",
|
||||
] as const;
|
||||
|
||||
type EconomyInboxLoader = (
|
||||
context: HousekeepingCapabilityContext,
|
||||
signal: AbortSignal,
|
||||
) => Promise<readonly HousekeepingWorkItem[]>;
|
||||
|
||||
export interface EconomyInboxAdapters {
|
||||
readonly catalog: EconomyInboxLoader;
|
||||
readonly commerce: EconomyInboxLoader;
|
||||
}
|
||||
|
||||
function createSource(
|
||||
id: (typeof ECONOMY_INBOX_SOURCE_IDS)[number],
|
||||
capability: CapabilityRequirement,
|
||||
load: EconomyInboxLoader,
|
||||
): HousekeepingInboxSource {
|
||||
return {
|
||||
id,
|
||||
owner: "economy",
|
||||
capability,
|
||||
async getItems(context, signal) {
|
||||
const authorization = authorizeHousekeeping(context, capability);
|
||||
if (!authorization.ok) return authorization;
|
||||
try {
|
||||
const items = await load(context, signal);
|
||||
return ok(
|
||||
{
|
||||
availability: "available" as const,
|
||||
items: items
|
||||
.filter(
|
||||
(item) =>
|
||||
isSafeHousekeepingHref(item.href) &&
|
||||
satisfiesCapability(context, item.capability),
|
||||
)
|
||||
.slice(0, 25),
|
||||
},
|
||||
authorization.correlationId,
|
||||
);
|
||||
} catch {
|
||||
return fail(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
authorization.correlationId,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createEconomyInboxSources(
|
||||
adapters: EconomyInboxAdapters,
|
||||
): readonly HousekeepingInboxSource[] {
|
||||
return [
|
||||
createSource(
|
||||
"economy.catalog-attention",
|
||||
anyCapability(PERMS.CATALOG_VIEW),
|
||||
adapters.catalog,
|
||||
),
|
||||
createSource(
|
||||
"economy.commerce-anomalies",
|
||||
anyCapability(PERMS.SHOP_VIEW),
|
||||
adapters.commerce,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export const ECONOMY_INBOX_SOURCES = createEconomyInboxSources({
|
||||
async catalog(context, signal) {
|
||||
const { loadEconomyInboxItems } = await import("./inbox-production");
|
||||
return loadEconomyInboxItems("catalog", context, signal);
|
||||
},
|
||||
async commerce(context, signal) {
|
||||
const { loadEconomyInboxItems } = await import("./inbox-production");
|
||||
return loadEconomyInboxItems("commerce", context, signal);
|
||||
},
|
||||
});
|
||||
@@ -3,22 +3,25 @@ import {
|
||||
anyCapability,
|
||||
type HousekeepingDomainManifest,
|
||||
} from "../../foundation/contracts";
|
||||
import { ECONOMY_INBOX_SOURCES } from "./inbox";
|
||||
import { ECONOMY_ROUTES } from "./routes";
|
||||
import { ECONOMY_SEARCH_PROVIDERS } from "./search";
|
||||
import { ECONOMY_WIDGETS } from "./widgets";
|
||||
|
||||
export const economyManifest = {
|
||||
id: "economy",
|
||||
labelKey: "pages.housekeeping.domains.economy.title",
|
||||
descriptionKey: "pages.housekeeping.domains.economy.description",
|
||||
iconId: "gem",
|
||||
previewHref: "/ase-next/economy",
|
||||
canonicalHref: "/ase-next/economy",
|
||||
capability: anyCapability(
|
||||
PERMS.CATALOG_VIEW,
|
||||
PERMS.SHOP_VIEW,
|
||||
PERMS.CATALOG_EDIT,
|
||||
PERMS.SHOP_EDIT,
|
||||
),
|
||||
landingRouteId: null,
|
||||
routes: [],
|
||||
searchProviders: [],
|
||||
inboxSources: [],
|
||||
widgets: [],
|
||||
routes: ECONOMY_ROUTES,
|
||||
searchProviders: ECONOMY_SEARCH_PROVIDERS,
|
||||
inboxSources: ECONOMY_INBOX_SOURCES,
|
||||
widgets: ECONOMY_WIDGETS,
|
||||
} satisfies HousekeepingDomainManifest;
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||
import { economyQuery } from "../queries/catalog";
|
||||
import { EconomyCommandForm } from "./economy-command-form";
|
||||
import {
|
||||
EconomyPageFrame,
|
||||
type EconomyPageProps,
|
||||
parseEconomyListInput,
|
||||
} from "./economy-page-frame";
|
||||
|
||||
function forms({ context, routeId }: EconomyPageProps) {
|
||||
if (!context.has(PERMS.CATALOG_EDIT)) return null;
|
||||
if (routeId === "economy.catalog.maintenance") {
|
||||
return (
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{[
|
||||
["fix-offers", "Fix catalog offers"],
|
||||
["fix-everything", "Run complete catalog repair"],
|
||||
["fix-sprite-ids", "Fix sprite identifiers"],
|
||||
["reconcile-ids", "Reconcile identifiers"],
|
||||
["align-ids", "Align identifiers"],
|
||||
["remove-duplicates", "Remove duplicate base items"],
|
||||
].map(([operation, label]) => (
|
||||
<EconomyCommandForm
|
||||
key={operation}
|
||||
commandId={`economy.catalog.maintenance.${operation}`}
|
||||
buttonLabel={label}
|
||||
input={{}}
|
||||
requiresReason
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const builderClub = routeId === "economy.catalog.builder-club-detail";
|
||||
return (
|
||||
<EconomyCommandForm
|
||||
commandId={
|
||||
builderClub
|
||||
? "economy.catalog.bc-page.change"
|
||||
: "economy.catalog.page.change"
|
||||
}
|
||||
buttonLabel="Save catalog page"
|
||||
input={{
|
||||
action: routeId === "economy.catalog.overview" ? "create" : "update",
|
||||
}}
|
||||
fields={[
|
||||
{ name: "id", label: "ID", type: "identifier" },
|
||||
{
|
||||
name: "caption",
|
||||
label: "Caption",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 128,
|
||||
},
|
||||
{
|
||||
name: "parentId",
|
||||
label: "Parent ID",
|
||||
type: "number",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "pageLayout",
|
||||
label: "Page layout",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 26,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function EconomyCatalogPage(
|
||||
props: EconomyPageProps & { readonly editor?: ReactNode },
|
||||
) {
|
||||
return (
|
||||
<EconomyPageFrame
|
||||
title="Catalog"
|
||||
description="Catalog pages, Builder Club and maintenance workflows."
|
||||
result={props.result}
|
||||
forms={props.editor ?? forms(props)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export async function renderEconomyCatalogPage(input: HousekeepingPageInput) {
|
||||
const routeId = input.match.routeId as EconomyPageProps["routeId"];
|
||||
const result = await economyQuery.run(input.context, {
|
||||
routeId: routeId ?? "economy.catalog.overview",
|
||||
params: input.match.params,
|
||||
list: parseEconomyListInput(input.searchParams ?? {}),
|
||||
});
|
||||
const editor =
|
||||
result.ok &&
|
||||
(routeId === "economy.catalog.detail" ||
|
||||
routeId === "economy.catalog.builder-club-detail")
|
||||
? await (
|
||||
await import("./specialized-catalog-editor")
|
||||
).renderSpecializedCatalogEditor(
|
||||
routeId,
|
||||
input.match.params,
|
||||
input.context,
|
||||
)
|
||||
: null;
|
||||
return (
|
||||
<EconomyCatalogPage
|
||||
context={input.context}
|
||||
result={result}
|
||||
routeId={routeId}
|
||||
editor={editor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||
import { economyQuery } from "../queries/catalog";
|
||||
import { EconomyCommandForm } from "./economy-command-form";
|
||||
import {
|
||||
EconomyPageFrame,
|
||||
type EconomyPageProps,
|
||||
parseEconomyListInput,
|
||||
} from "./economy-page-frame";
|
||||
|
||||
function commerceForm({ context, routeId }: EconomyPageProps) {
|
||||
if (!context.has(PERMS.SHOP_EDIT)) return null;
|
||||
if (routeId === "economy.commerce.vouchers") {
|
||||
return (
|
||||
<EconomyCommandForm
|
||||
commandId="economy.commerce.voucher.change"
|
||||
buttonLabel="Create voucher"
|
||||
input={{ action: "create" }}
|
||||
fields={[
|
||||
{
|
||||
name: "code",
|
||||
label: "Code",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
name: "amount",
|
||||
label: "Amount",
|
||||
type: "number",
|
||||
required: true,
|
||||
min: 1,
|
||||
},
|
||||
{
|
||||
name: "maxUses",
|
||||
label: "Maximum uses",
|
||||
type: "number",
|
||||
required: true,
|
||||
min: 1,
|
||||
},
|
||||
{ name: "expiresAt", label: "Expires at", type: "text" },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (routeId === "economy.commerce.marketplace") {
|
||||
return (
|
||||
<EconomyCommandForm
|
||||
commandId="economy.commerce.marketplace.cancel"
|
||||
buttonLabel="Cancel listing"
|
||||
input={{}}
|
||||
fields={[
|
||||
{
|
||||
name: "id",
|
||||
label: "Listing ID",
|
||||
type: "identifier",
|
||||
required: true,
|
||||
},
|
||||
]}
|
||||
requiresReason
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (routeId?.startsWith("economy.commerce.shop")) {
|
||||
return (
|
||||
<EconomyCommandForm
|
||||
commandId="economy.commerce.shop-article.change"
|
||||
buttonLabel="Save shop package"
|
||||
input={{
|
||||
action:
|
||||
routeId === "economy.commerce.shop-detail" ? "update" : "create",
|
||||
}}
|
||||
fields={[
|
||||
{ name: "id", label: "ID", type: "identifier" },
|
||||
{
|
||||
name: "name",
|
||||
label: "Name",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
name: "info",
|
||||
label: "Information",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
name: "costs",
|
||||
label: "Costs",
|
||||
type: "number",
|
||||
required: true,
|
||||
min: 0,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function EconomyCommercePage(props: EconomyPageProps) {
|
||||
return (
|
||||
<EconomyPageFrame
|
||||
title="Commerce"
|
||||
description="Shop, marketplace, vouchers and subscriptions."
|
||||
result={props.result}
|
||||
forms={commerceForm(props)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export async function renderEconomyCommercePage(input: HousekeepingPageInput) {
|
||||
const routeId = input.match.routeId as EconomyPageProps["routeId"];
|
||||
const result = await economyQuery.run(input.context, {
|
||||
routeId: routeId ?? "economy.commerce.shop",
|
||||
params: input.match.params,
|
||||
list: parseEconomyListInput(input.searchParams ?? {}),
|
||||
});
|
||||
return (
|
||||
<EconomyCommercePage
|
||||
context={input.context}
|
||||
result={result}
|
||||
routeId={routeId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import type { HousekeepingResult } from "../../../foundation/contracts";
|
||||
|
||||
export interface EconomyCommandField {
|
||||
readonly name: string;
|
||||
readonly label: string;
|
||||
readonly type:
|
||||
| "identifier"
|
||||
| "text"
|
||||
| "textarea"
|
||||
| "number"
|
||||
| "checkbox"
|
||||
| "file";
|
||||
readonly required?: boolean;
|
||||
readonly min?: number;
|
||||
readonly max?: number;
|
||||
readonly maxLength?: number;
|
||||
readonly defaultValue?: string | number | boolean;
|
||||
readonly allowUnchanged?: boolean;
|
||||
}
|
||||
|
||||
export interface EconomyCommandSubmission {
|
||||
readonly commandId: string;
|
||||
readonly input: Readonly<Record<string, unknown>>;
|
||||
readonly fields?: readonly EconomyCommandField[];
|
||||
readonly requiresReason?: boolean;
|
||||
}
|
||||
|
||||
interface EconomyCommandFormProps extends EconomyCommandSubmission {
|
||||
readonly buttonLabel: string;
|
||||
}
|
||||
|
||||
const OMIT_FIELD = Symbol("omit optional Economy command field");
|
||||
|
||||
function parseField(field: EconomyCommandField, formData: FormData): unknown {
|
||||
const rawValue = formData.get(field.name);
|
||||
if (field.type === "checkbox") {
|
||||
if (field.allowUnchanged) {
|
||||
if (rawValue === null || rawValue === "") return OMIT_FIELD;
|
||||
if (rawValue === "true") return true;
|
||||
if (rawValue === "false") return false;
|
||||
return OMIT_FIELD;
|
||||
}
|
||||
return rawValue === "on";
|
||||
}
|
||||
if (rawValue === null && !field.required) return OMIT_FIELD;
|
||||
if (field.type === "file") return rawValue instanceof File ? rawValue : null;
|
||||
const raw = String(rawValue ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!raw && !field.required) return OMIT_FIELD;
|
||||
if (field.type === "number") {
|
||||
const value = Number(raw);
|
||||
if (!Number.isSafeInteger(value)) return 0;
|
||||
return Math.min(field.max ?? value, Math.max(field.min ?? value, value));
|
||||
}
|
||||
return raw.slice(
|
||||
0,
|
||||
field.maxLength ?? (field.type === "textarea" ? 20_000 : 500),
|
||||
);
|
||||
}
|
||||
|
||||
const initialState: HousekeepingResult<unknown> | null = null;
|
||||
|
||||
export async function submitEconomyCommandForm(
|
||||
configuration: EconomyCommandSubmission,
|
||||
_previous: HousekeepingResult<unknown> | null,
|
||||
formData: FormData,
|
||||
): Promise<HousekeepingResult<unknown>> {
|
||||
const submitted = (configuration.fields ?? []).flatMap((field) => {
|
||||
const value = parseField(field, formData);
|
||||
return value === OMIT_FIELD ? [] : [[field.name, value] as const];
|
||||
});
|
||||
const reason = String(formData.get("reason") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 1000);
|
||||
const { executeHousekeepingCommand } = await import(
|
||||
"@/actions/housekeeping-command"
|
||||
);
|
||||
return executeHousekeepingCommand({
|
||||
commandId: configuration.commandId,
|
||||
input: { ...configuration.input, ...Object.fromEntries(submitted) },
|
||||
...(configuration.requiresReason ? { reason } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function EconomyCommandForm({
|
||||
commandId,
|
||||
buttonLabel,
|
||||
input,
|
||||
fields = [],
|
||||
requiresReason = false,
|
||||
}: EconomyCommandFormProps) {
|
||||
const [result, submit, pending] = useActionState(
|
||||
submitEconomyCommandForm.bind(null, {
|
||||
commandId,
|
||||
input,
|
||||
fields,
|
||||
requiresReason,
|
||||
}),
|
||||
initialState,
|
||||
);
|
||||
return (
|
||||
<form
|
||||
action={submit}
|
||||
data-housekeeping-command={commandId}
|
||||
className="space-y-3 rounded border border-[var(--admin-border)] p-3"
|
||||
>
|
||||
{fields.map((field) => {
|
||||
const fieldId = `economy-${commandId}-${field.name}`;
|
||||
return (
|
||||
<label key={field.name} htmlFor={fieldId} className="block text-sm">
|
||||
{field.type === "checkbox" && field.allowUnchanged ? (
|
||||
<>
|
||||
{field.label}
|
||||
<select
|
||||
id={fieldId}
|
||||
name={field.name}
|
||||
defaultValue=""
|
||||
className="mt-1 block w-full"
|
||||
>
|
||||
<option value="">No change</option>
|
||||
<option value="true">Enabled</option>
|
||||
<option value="false">Disabled</option>
|
||||
</select>
|
||||
</>
|
||||
) : field.type === "checkbox" ? (
|
||||
<>
|
||||
<input
|
||||
id={fieldId}
|
||||
name={field.name}
|
||||
type="checkbox"
|
||||
defaultChecked={field.defaultValue === true}
|
||||
/>{" "}
|
||||
{field.label}
|
||||
</>
|
||||
) : field.type === "textarea" ? (
|
||||
<>
|
||||
{field.label}
|
||||
<textarea
|
||||
id={fieldId}
|
||||
name={field.name}
|
||||
required={field.required}
|
||||
maxLength={field.maxLength}
|
||||
className="mt-1 block w-full"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{field.label}
|
||||
<input
|
||||
id={fieldId}
|
||||
name={field.name}
|
||||
type={
|
||||
field.type === "number"
|
||||
? "number"
|
||||
: field.type === "file"
|
||||
? "file"
|
||||
: "text"
|
||||
}
|
||||
required={field.required}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
maxLength={field.maxLength}
|
||||
className="mt-1 block w-full"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{requiresReason ? (
|
||||
<label
|
||||
htmlFor={`economy-${commandId}-reason`}
|
||||
className="block text-sm"
|
||||
>
|
||||
Reason
|
||||
<textarea
|
||||
id={`economy-${commandId}-reason`}
|
||||
name="reason"
|
||||
required
|
||||
maxLength={1000}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
<button type="submit" disabled={pending}>
|
||||
{pending ? "Working..." : buttonLabel}
|
||||
</button>
|
||||
{result ? (
|
||||
<p role="status">
|
||||
{result.ok ? "Completed" : "Failed"} ({result.correlationId})
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type {
|
||||
HousekeepingCapabilityContext,
|
||||
HousekeepingResult,
|
||||
} from "../../../foundation/contracts";
|
||||
import type { EconomyQueryData } from "../queries/catalog";
|
||||
import type { EconomyRouteId } from "../routes";
|
||||
|
||||
export interface EconomyPageProps {
|
||||
readonly context: HousekeepingCapabilityContext;
|
||||
readonly result?: HousekeepingResult<EconomyQueryData>;
|
||||
readonly routeId: EconomyRouteId | null;
|
||||
}
|
||||
|
||||
export function EconomyPageFrame({
|
||||
title,
|
||||
description,
|
||||
result,
|
||||
forms,
|
||||
}: {
|
||||
readonly title: string;
|
||||
readonly description: string;
|
||||
readonly result?: HousekeepingResult<EconomyQueryData>;
|
||||
readonly forms?: ReactNode;
|
||||
}) {
|
||||
if (!result) {
|
||||
return (
|
||||
<section data-housekeeping-state="loading">
|
||||
<h1>{title}</h1>
|
||||
<p>{description}</p>
|
||||
<p>Loading economy data…</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
if (!result.ok) {
|
||||
const state = result.error.code === "FORBIDDEN" ? "forbidden" : "error";
|
||||
return (
|
||||
<section data-housekeeping-state={state} role="alert">
|
||||
<h1>{title}</h1>
|
||||
<p>{result.error.messageKey}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
const state = result.data.items.length === 0 ? "empty" : "ready";
|
||||
return (
|
||||
<section data-housekeeping-state={state} className="space-y-4">
|
||||
<header>
|
||||
<h1>{title}</h1>
|
||||
<p>{description}</p>
|
||||
</header>
|
||||
{forms}
|
||||
{result.data.items.length === 0 ? (
|
||||
<p>No matching economy records.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-[var(--admin-border)]">
|
||||
{result.data.items.map((item) => (
|
||||
<li key={item.id} className="py-2">
|
||||
{item.href ? <a href={item.href}>{item.title}</a> : item.title}
|
||||
{item.status ? <span> — {item.status}</span> : null}
|
||||
{item.amount ? <span> — {item.amount}</span> : null}
|
||||
{item.description ? <p>{item.description}</p> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function parseEconomyListInput(
|
||||
searchParams: Readonly<
|
||||
Record<string, string | readonly string[] | undefined>
|
||||
>,
|
||||
) {
|
||||
const first = (value: string | readonly string[] | undefined) =>
|
||||
Array.isArray(value) ? value[0] : value;
|
||||
return {
|
||||
search: first(searchParams.search),
|
||||
pageSize: Number(first(searchParams.pageSize) ?? 25),
|
||||
offset: Number(first(searchParams.offset) ?? 0),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import {
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
ok,
|
||||
} from "../../../foundation/contracts";
|
||||
import { EconomyCatalogPage } from "./catalog";
|
||||
import { EconomyCommercePage } from "./commerce";
|
||||
import { EconomyHistoryPage } from "./history";
|
||||
import { EconomyItemsPage } from "./items";
|
||||
import { EconomyRewardsPage } from "./rewards";
|
||||
import { EconomyValuePage } from "./value";
|
||||
|
||||
function context(granted: readonly string[]): HousekeepingCapabilityContext {
|
||||
const permissions = new Set(granted);
|
||||
return {
|
||||
actor: { id: 42, username: "operator", rank: 7 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => permissions.has(slug),
|
||||
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
||||
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
||||
};
|
||||
}
|
||||
|
||||
const cases = [
|
||||
["catalog", EconomyCatalogPage],
|
||||
["items", EconomyItemsPage],
|
||||
["commerce", EconomyCommercePage],
|
||||
["history", EconomyHistoryPage],
|
||||
["value", EconomyValuePage],
|
||||
["rewards", EconomyRewardsPage],
|
||||
] as const;
|
||||
|
||||
describe.each(cases)("Economy %s page", (kind, Component) => {
|
||||
it("renders loading, forbidden, empty, and canonical ready states", () => {
|
||||
const render = (result?: unknown) =>
|
||||
renderToStaticMarkup(
|
||||
<Component
|
||||
context={context(Object.values(PERMS))}
|
||||
result={result as never}
|
||||
routeId={null}
|
||||
/>,
|
||||
);
|
||||
expect(render()).toContain('data-housekeeping-state="loading"');
|
||||
expect(
|
||||
render(fail("FORBIDDEN", "errors.housekeeping.forbidden", "denied")),
|
||||
).toContain('data-housekeeping-state="forbidden"');
|
||||
expect(
|
||||
render(
|
||||
ok({ kind, items: [], total: 0, partialDependencies: [] }, "empty"),
|
||||
),
|
||||
).toContain('data-housekeeping-state="empty"');
|
||||
const ready = render(
|
||||
ok(
|
||||
{
|
||||
kind,
|
||||
items: [
|
||||
{
|
||||
id: "18446744073709551615",
|
||||
title: "Canonical economy item",
|
||||
status: "active",
|
||||
href: `/ase-next/economy/${kind}`,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
partialDependencies: [],
|
||||
},
|
||||
"ready",
|
||||
),
|
||||
);
|
||||
expect(ready).toContain('data-housekeeping-state="ready"');
|
||||
expect(ready).toContain("Canonical economy item");
|
||||
expect(ready).not.toContain("/admin");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Economy mutation forms", () => {
|
||||
it("renders edit controls only with the exact mutation capability", () => {
|
||||
const result = ok(
|
||||
{
|
||||
kind: "commerce" as const,
|
||||
items: [{ id: "1", title: "Package", status: "active" }],
|
||||
total: 1,
|
||||
partialDependencies: [],
|
||||
},
|
||||
"shop",
|
||||
);
|
||||
const readOnly = renderToStaticMarkup(
|
||||
<EconomyCommercePage
|
||||
context={context([PERMS.SHOP_VIEW])}
|
||||
result={result}
|
||||
routeId="economy.commerce.shop"
|
||||
/>,
|
||||
);
|
||||
const editor = renderToStaticMarkup(
|
||||
<EconomyCommercePage
|
||||
context={context([PERMS.SHOP_VIEW, PERMS.SHOP_EDIT])}
|
||||
result={result}
|
||||
routeId="economy.commerce.shop"
|
||||
/>,
|
||||
);
|
||||
expect(readOnly).not.toContain("economy.commerce.shop-article.change");
|
||||
expect(editor).toContain("economy.commerce.shop-article.change");
|
||||
expect(editor).toContain("<form");
|
||||
});
|
||||
|
||||
it("renders validator-shaped catalog, voucher, value, and soundtrack fields", () => {
|
||||
const all = context(Object.values(PERMS));
|
||||
const empty = (kind: "catalog" | "commerce" | "value" | "rewards") =>
|
||||
ok({ kind, items: [], total: 0, partialDependencies: [] }, kind);
|
||||
const catalog = renderToStaticMarkup(
|
||||
<EconomyCatalogPage
|
||||
context={all}
|
||||
result={empty("catalog")}
|
||||
routeId="economy.catalog.detail"
|
||||
/>,
|
||||
);
|
||||
for (const field of ["id", "caption", "parentId", "pageLayout"]) {
|
||||
expect(catalog).toContain(`name="${field}"`);
|
||||
}
|
||||
const commerce = renderToStaticMarkup(
|
||||
<EconomyCommercePage
|
||||
context={all}
|
||||
result={empty("commerce")}
|
||||
routeId="economy.commerce.vouchers"
|
||||
/>,
|
||||
);
|
||||
for (const field of ["code", "amount", "maxUses", "expiresAt"]) {
|
||||
expect(commerce).toContain(`name="${field}"`);
|
||||
}
|
||||
const value = renderToStaticMarkup(
|
||||
<EconomyValuePage
|
||||
context={all}
|
||||
result={empty("value")}
|
||||
routeId="economy.value.rare-values"
|
||||
/>,
|
||||
);
|
||||
for (const field of [
|
||||
"categoryId",
|
||||
"name",
|
||||
"currencyValue",
|
||||
"currencyType",
|
||||
]) {
|
||||
expect(value).toContain(`name="${field}"`);
|
||||
}
|
||||
const rewards = renderToStaticMarkup(
|
||||
<EconomyRewardsPage
|
||||
context={all}
|
||||
result={empty("rewards")}
|
||||
routeId="economy.rewards.sounds"
|
||||
/>,
|
||||
);
|
||||
for (const field of ["id", "name", "author", "track", "length"]) {
|
||||
expect(rewards).toContain(`name="${field}"`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||
import { economyQuery } from "../queries/catalog";
|
||||
import {
|
||||
EconomyPageFrame,
|
||||
type EconomyPageProps,
|
||||
parseEconomyListInput,
|
||||
} from "./economy-page-frame";
|
||||
|
||||
export function EconomyHistoryPage(props: EconomyPageProps) {
|
||||
return (
|
||||
<EconomyPageFrame
|
||||
title="Transaction history"
|
||||
description="Stable, read-only commerce history."
|
||||
result={props.result}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export async function renderEconomyHistoryPage(input: HousekeepingPageInput) {
|
||||
const routeId = input.match.routeId as EconomyPageProps["routeId"];
|
||||
const result = await economyQuery.run(input.context, {
|
||||
routeId: routeId ?? "economy.history.transactions",
|
||||
params: input.match.params,
|
||||
list: parseEconomyListInput(input.searchParams ?? {}),
|
||||
});
|
||||
return (
|
||||
<EconomyHistoryPage
|
||||
context={input.context}
|
||||
result={result}
|
||||
routeId={routeId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||
import { economyQuery } from "../queries/catalog";
|
||||
import { EconomyCommandForm } from "./economy-command-form";
|
||||
import {
|
||||
EconomyPageFrame,
|
||||
type EconomyPageProps,
|
||||
parseEconomyListInput,
|
||||
} from "./economy-page-frame";
|
||||
|
||||
export function EconomyItemsPage(props: EconomyPageProps) {
|
||||
const edit = props.context.has(PERMS.CATALOG_EDIT) ? (
|
||||
<EconomyCommandForm
|
||||
commandId="economy.items.base.update"
|
||||
buttonLabel="Save base item"
|
||||
input={{}}
|
||||
fields={[
|
||||
{ name: "id", label: "ID", type: "identifier", required: true },
|
||||
{
|
||||
name: "publicName",
|
||||
label: "Public name",
|
||||
type: "text",
|
||||
maxLength: 56,
|
||||
},
|
||||
{ name: "itemName", label: "Item name", type: "text", maxLength: 70 },
|
||||
{
|
||||
name: "interactionType",
|
||||
label: "Interaction",
|
||||
type: "text",
|
||||
maxLength: 500,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null;
|
||||
return (
|
||||
<EconomyPageFrame
|
||||
title="Items"
|
||||
description="Base item definitions and catalog linkage."
|
||||
result={props.result}
|
||||
forms={edit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export async function renderEconomyItemsPage(input: HousekeepingPageInput) {
|
||||
const routeId = input.match.routeId as EconomyPageProps["routeId"];
|
||||
const result = await economyQuery.run(input.context, {
|
||||
routeId: routeId ?? "economy.items.overview",
|
||||
params: input.match.params,
|
||||
list: parseEconomyListInput(input.searchParams ?? {}),
|
||||
});
|
||||
return (
|
||||
<EconomyItemsPage
|
||||
context={input.context}
|
||||
result={result}
|
||||
routeId={routeId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||
import { economyQuery } from "../queries/catalog";
|
||||
import { EconomyCommandForm } from "./economy-command-form";
|
||||
import {
|
||||
EconomyPageFrame,
|
||||
type EconomyPageProps,
|
||||
parseEconomyListInput,
|
||||
} from "./economy-page-frame";
|
||||
|
||||
function rewardForms({ context, routeId }: EconomyPageProps) {
|
||||
if (!context.has(PERMS.CATALOG_EDIT)) return null;
|
||||
if (routeId === "economy.rewards.sounds") {
|
||||
return (
|
||||
<EconomyCommandForm
|
||||
commandId="economy.rewards.soundtrack.change"
|
||||
buttonLabel="Save soundtrack"
|
||||
input={{ action: "update" }}
|
||||
fields={[
|
||||
{ name: "id", label: "ID", type: "identifier", required: true },
|
||||
{
|
||||
name: "name",
|
||||
label: "Name",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 100,
|
||||
},
|
||||
{
|
||||
name: "author",
|
||||
label: "Author",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 50,
|
||||
},
|
||||
{ name: "track", label: "Track", type: "textarea", required: true },
|
||||
{
|
||||
name: "length",
|
||||
label: "Length",
|
||||
type: "number",
|
||||
required: true,
|
||||
min: 0,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (routeId === "economy.rewards.badges") {
|
||||
return (
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<EconomyCommandForm
|
||||
commandId="economy.rewards.badge.give"
|
||||
buttonLabel="Give badge"
|
||||
input={{}}
|
||||
fields={[
|
||||
{
|
||||
name: "username",
|
||||
label: "Username",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 25,
|
||||
},
|
||||
{
|
||||
name: "badge",
|
||||
label: "Badge code",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 32,
|
||||
},
|
||||
]}
|
||||
requiresReason
|
||||
/>
|
||||
<EconomyCommandForm
|
||||
commandId="economy.rewards.badge.upload"
|
||||
buttonLabel="Upload badge"
|
||||
input={{}}
|
||||
fields={[
|
||||
{
|
||||
name: "code",
|
||||
label: "Badge code",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 32,
|
||||
},
|
||||
{ name: "file", label: "Badge file", type: "file", required: true },
|
||||
]}
|
||||
requiresReason
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function EconomyRewardsPage(props: EconomyPageProps) {
|
||||
return (
|
||||
<EconomyPageFrame
|
||||
title="Rewards"
|
||||
description="Badges, achievements, sounds and calendar rewards."
|
||||
result={props.result}
|
||||
forms={rewardForms(props)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export async function renderEconomyRewardsPage(input: HousekeepingPageInput) {
|
||||
const routeId = input.match.routeId as EconomyPageProps["routeId"];
|
||||
const result = await economyQuery.run(input.context, {
|
||||
routeId: routeId ?? "economy.rewards.badges",
|
||||
params: input.match.params,
|
||||
list: parseEconomyListInput(input.searchParams ?? {}),
|
||||
});
|
||||
return (
|
||||
<EconomyRewardsPage
|
||||
context={input.context}
|
||||
result={result}
|
||||
routeId={routeId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import "server-only";
|
||||
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import type { ReactNode } from "react";
|
||||
import { BcPageDetail } from "@/components/admin/catalog/builder-club/bc-manager";
|
||||
import { CatalogDetailTabs } from "@/components/admin/catalog/detail/catalog-detail-tabs";
|
||||
import { CatalogItemsBc, CatalogPages, CatalogPagesBc, db } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { loadCatalogItemsData } from "@/lib/services/catalog-items-loader";
|
||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||
import type { EconomyRouteId } from "../routes";
|
||||
|
||||
async function normalCatalogEditor(
|
||||
id: number,
|
||||
context: HousekeepingCapabilityContext,
|
||||
): Promise<ReactNode> {
|
||||
const [catalogPage] = await db
|
||||
.select()
|
||||
.from(CatalogPages)
|
||||
.where(eq(CatalogPages.id, id))
|
||||
.limit(1);
|
||||
if (!catalogPage) return null;
|
||||
const [children, itemsData] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: CatalogPages.id,
|
||||
caption: CatalogPages.caption,
|
||||
enabled: CatalogPages.enabled,
|
||||
})
|
||||
.from(CatalogPages)
|
||||
.where(eq(CatalogPages.parentId, id))
|
||||
.orderBy(asc(CatalogPages.orderNum)),
|
||||
loadCatalogItemsData(id),
|
||||
]);
|
||||
return (
|
||||
<CatalogDetailTabs
|
||||
catalogPage={{
|
||||
id: catalogPage.id,
|
||||
caption: catalogPage.caption,
|
||||
parentId: catalogPage.parentId,
|
||||
pageLayout: catalogPage.pageLayout,
|
||||
enabled: catalogPage.enabled,
|
||||
visible: catalogPage.visible,
|
||||
minRank: catalogPage.minRank,
|
||||
clubOnly: catalogPage.clubOnly,
|
||||
orderNum: catalogPage.orderNum,
|
||||
iconImage: catalogPage.iconImage,
|
||||
iconColor: catalogPage.iconColor,
|
||||
pageHeadline: catalogPage.pageHeadline,
|
||||
pageTeaser: catalogPage.pageTeaser,
|
||||
pageSpecial: catalogPage.pageSpecial,
|
||||
pageText1: catalogPage.pageText1,
|
||||
pageText2: catalogPage.pageText2,
|
||||
pageTextDetails: catalogPage.pageTextDetails,
|
||||
pageTextTeaser: catalogPage.pageTextTeaser,
|
||||
}}
|
||||
items={itemsData.items}
|
||||
baseItems={itemsData.baseItems}
|
||||
catalogNameMap={itemsData.catalogNameMap}
|
||||
childPages={children}
|
||||
pageId={catalogPage.id}
|
||||
canEdit={context.has(PERMS.CATALOG_EDIT)}
|
||||
furniDataIdList={itemsData.furniDataIdList}
|
||||
furniDescriptionMap={itemsData.furniDescriptionMap}
|
||||
furniRevisionMap={itemsData.furniRevisionMap}
|
||||
allPages={itemsData.allPages}
|
||||
interactionTypes={itemsData.interactionTypes}
|
||||
gamedataHotel={itemsData.gamedataHotel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function builderClubEditor(
|
||||
id: number,
|
||||
context: HousekeepingCapabilityContext,
|
||||
): Promise<ReactNode> {
|
||||
const [page] = await db
|
||||
.select()
|
||||
.from(CatalogPagesBc)
|
||||
.where(eq(CatalogPagesBc.id, id))
|
||||
.limit(1);
|
||||
if (!page) return null;
|
||||
const items = await db
|
||||
.select()
|
||||
.from(CatalogItemsBc)
|
||||
.where(eq(CatalogItemsBc.pageId, id))
|
||||
.orderBy(asc(CatalogItemsBc.orderNumber));
|
||||
return (
|
||||
<BcPageDetail
|
||||
page={{
|
||||
id: page.id,
|
||||
parentId: page.parentId,
|
||||
caption: page.caption,
|
||||
pageLayout: page.pageLayout,
|
||||
iconColor: page.iconColor,
|
||||
iconImage: page.iconImage,
|
||||
orderNum: page.orderNum,
|
||||
visible: page.visible,
|
||||
enabled: page.enabled,
|
||||
pageHeadline: page.pageHeadline,
|
||||
pageTeaser: page.pageTeaser,
|
||||
pageSpecial: page.pageSpecial ?? "",
|
||||
pageText1: page.pageText1 ?? "",
|
||||
pageText2: page.pageText2 ?? "",
|
||||
pageTextDetails: page.pageTextDetails ?? "",
|
||||
pageTextTeaser: page.pageTextTeaser ?? "",
|
||||
}}
|
||||
items={items.map((item) => ({
|
||||
id: item.id,
|
||||
pageId: item.pageId,
|
||||
itemIds: item.itemIds,
|
||||
catalogName: item.catalogName,
|
||||
orderNumber: item.orderNumber,
|
||||
extradata: item.extradata,
|
||||
}))}
|
||||
canEdit={context.has(PERMS.CATALOG_EDIT)}
|
||||
returnHref="/ase-next/economy/catalog"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export async function renderSpecializedCatalogEditor(
|
||||
routeId: EconomyRouteId,
|
||||
params: Readonly<Record<string, string>>,
|
||||
context: HousekeepingCapabilityContext,
|
||||
): Promise<ReactNode> {
|
||||
const id = Number(params.id);
|
||||
if (!Number.isSafeInteger(id) || id <= 0) return null;
|
||||
if (routeId === "economy.catalog.detail") {
|
||||
return normalCatalogEditor(id, context);
|
||||
}
|
||||
if (routeId === "economy.catalog.builder-club-detail") {
|
||||
return builderClubEditor(id, context);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||
import { economyQuery } from "../queries/catalog";
|
||||
import { EconomyCommandForm } from "./economy-command-form";
|
||||
import {
|
||||
EconomyPageFrame,
|
||||
type EconomyPageProps,
|
||||
parseEconomyListInput,
|
||||
} from "./economy-page-frame";
|
||||
|
||||
export function EconomyValuePage(props: EconomyPageProps) {
|
||||
const forms = props.context.has(PERMS.SHOP_EDIT) ? (
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<EconomyCommandForm
|
||||
commandId="economy.value.category.change"
|
||||
buttonLabel="Create value category"
|
||||
input={{ action: "create" }}
|
||||
fields={[
|
||||
{
|
||||
name: "name",
|
||||
label: "Name",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
name: "badge",
|
||||
label: "Badge",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 255,
|
||||
},
|
||||
{ name: "priority", label: "Priority", type: "number", min: 1 },
|
||||
]}
|
||||
/>
|
||||
<EconomyCommandForm
|
||||
commandId="economy.value.rare.change"
|
||||
buttonLabel="Create rare value"
|
||||
input={{ action: "create" }}
|
||||
fields={[
|
||||
{
|
||||
name: "categoryId",
|
||||
label: "Category ID",
|
||||
type: "identifier",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "name",
|
||||
label: "Name",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
name: "currencyValue",
|
||||
label: "Currency value",
|
||||
type: "text",
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
name: "currencyType",
|
||||
label: "Currency type",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
name: "furnitureIcon",
|
||||
label: "Furniture icon",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 255,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
return (
|
||||
<EconomyPageFrame
|
||||
title="Value tools"
|
||||
description="Rare-value categories and valuations."
|
||||
result={props.result}
|
||||
forms={forms}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export async function renderEconomyValuePage(input: HousekeepingPageInput) {
|
||||
const routeId = input.match.routeId as EconomyPageProps["routeId"];
|
||||
const result = await economyQuery.run(input.context, {
|
||||
routeId: routeId ?? "economy.value.rare-values",
|
||||
params: input.match.params,
|
||||
list: parseEconomyListInput(input.searchParams ?? {}),
|
||||
});
|
||||
return (
|
||||
<EconomyValuePage
|
||||
context={input.context}
|
||||
result={result}
|
||||
routeId={routeId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { authorizeHousekeeping } from "../../../foundation/authorization";
|
||||
import {
|
||||
fail,
|
||||
type HousekeepingQuery,
|
||||
ok,
|
||||
} from "../../../foundation/contracts";
|
||||
import { isSafeHousekeepingHref } from "../../../foundation/housekeeping-href";
|
||||
import {
|
||||
type EconomyRouteGroup,
|
||||
type EconomyRouteId,
|
||||
economyRouteById,
|
||||
economyRouteGroup,
|
||||
} from "../routes";
|
||||
|
||||
export interface EconomyQueryInput {
|
||||
readonly routeId: EconomyRouteId;
|
||||
readonly params?: Readonly<Record<string, string>>;
|
||||
readonly list?: Readonly<{
|
||||
search?: string;
|
||||
pageSize?: number;
|
||||
offset?: number;
|
||||
}>;
|
||||
}
|
||||
export interface EconomyQueryItem {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly status?: string;
|
||||
readonly amount?: string;
|
||||
readonly href?: string;
|
||||
readonly description?: string;
|
||||
readonly updatedAt?: string | null;
|
||||
}
|
||||
export interface EconomyQueryData {
|
||||
readonly kind: EconomyRouteGroup;
|
||||
readonly items: readonly EconomyQueryItem[];
|
||||
readonly total: number;
|
||||
readonly partialDependencies: readonly string[];
|
||||
}
|
||||
export interface EconomyQueryAdapters {
|
||||
load(input: NormalizedEconomyQueryInput): Promise<EconomyQueryData>;
|
||||
}
|
||||
|
||||
export interface NormalizedEconomyQueryInput {
|
||||
readonly routeId: EconomyRouteId;
|
||||
readonly params: Readonly<Record<string, string>>;
|
||||
readonly list: Readonly<{ search: string; pageSize: number; offset: number }>;
|
||||
}
|
||||
|
||||
function boundedInteger(
|
||||
value: unknown,
|
||||
fallback: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed)) return fallback;
|
||||
return Math.min(maximum, Math.max(minimum, parsed));
|
||||
}
|
||||
|
||||
function normalizeInput(input: EconomyQueryInput): NormalizedEconomyQueryInput {
|
||||
return {
|
||||
routeId: input.routeId,
|
||||
params: Object.fromEntries(
|
||||
Object.entries(input.params ?? {}).map(([key, value]) => [
|
||||
key.normalize("NFC").trim().slice(0, 128),
|
||||
value.normalize("NFC").trim().slice(0, 128),
|
||||
]),
|
||||
),
|
||||
list: {
|
||||
search: String(input.list?.search ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 128),
|
||||
pageSize: boundedInteger(input.list?.pageSize, 25, 1, 100),
|
||||
offset: boundedInteger(input.list?.offset, 0, 0, 100_000),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isValidData(
|
||||
data: EconomyQueryData,
|
||||
input: NormalizedEconomyQueryInput,
|
||||
): boolean {
|
||||
if (
|
||||
data.kind !== economyRouteGroup(input.routeId) ||
|
||||
!Array.isArray(data.items) ||
|
||||
!Number.isSafeInteger(data.total) ||
|
||||
data.total < 0 ||
|
||||
!Array.isArray(data.partialDependencies) ||
|
||||
data.partialDependencies.length !== 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return data.items.every(
|
||||
(item) =>
|
||||
typeof item.id === "string" &&
|
||||
item.id.length > 0 &&
|
||||
typeof item.title === "string" &&
|
||||
item.title.length > 0 &&
|
||||
(item.href === undefined || isSafeHousekeepingHref(item.href)),
|
||||
);
|
||||
}
|
||||
export function createEconomyQuery(
|
||||
adapters: EconomyQueryAdapters,
|
||||
): HousekeepingQuery<EconomyQueryInput, EconomyQueryData> {
|
||||
return {
|
||||
id: "economy.query",
|
||||
owner: "economy",
|
||||
capability: economyRouteById("economy.catalog.overview").capability,
|
||||
async run(context, rawInput) {
|
||||
const route = economyRouteById(rawInput.routeId);
|
||||
const authorization = authorizeHousekeeping(context, route.capability);
|
||||
if (!authorization.ok) return authorization;
|
||||
const input = normalizeInput(rawInput);
|
||||
try {
|
||||
const data = await adapters.load(input);
|
||||
if (!isValidData(data, input)) throw new Error("invalid Economy query");
|
||||
return ok(data, authorization.correlationId);
|
||||
} catch {
|
||||
return fail(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
authorization.correlationId,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadEconomyCatalogQuery(
|
||||
input: NormalizedEconomyQueryInput,
|
||||
): Promise<EconomyQueryData> {
|
||||
const { loadEconomyDatabaseQuery } = await import("./economy-production");
|
||||
return loadEconomyDatabaseQuery(input);
|
||||
}
|
||||
|
||||
export const economyQuery = createEconomyQuery({
|
||||
async load(input) {
|
||||
const group = economyRouteGroup(input.routeId);
|
||||
if (group === "catalog" || group === "items") {
|
||||
return loadEconomyCatalogQuery(input);
|
||||
}
|
||||
if (group === "commerce" || group === "history") {
|
||||
const { loadEconomyCommerceQuery } = await import("./commerce");
|
||||
return loadEconomyCommerceQuery(input);
|
||||
}
|
||||
const { loadEconomyValueQuery } = await import("./value");
|
||||
return loadEconomyValueQuery(input);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { EconomyQueryData, NormalizedEconomyQueryInput } from "./catalog";
|
||||
|
||||
export function serializeEconomyAmount(
|
||||
value: string | number,
|
||||
currency: string,
|
||||
): string {
|
||||
const raw =
|
||||
typeof value === "number" && Number.isFinite(value)
|
||||
? value.toFixed(2)
|
||||
: String(value).trim();
|
||||
return `${currency.normalize("NFC").trim().toUpperCase().slice(0, 8)} ${raw}`;
|
||||
}
|
||||
|
||||
export async function loadEconomyCommerceQuery(
|
||||
input: NormalizedEconomyQueryInput,
|
||||
): Promise<EconomyQueryData> {
|
||||
const { loadEconomyDatabaseQuery } = await import("./economy-production");
|
||||
return loadEconomyDatabaseQuery(input);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { EconomyRouteId } from "../routes";
|
||||
import type { NormalizedEconomyQueryInput } from "./catalog";
|
||||
import {
|
||||
ECONOMY_QUERY_DEFINITIONS,
|
||||
loadEconomyDatabaseQueryWith,
|
||||
} from "./economy-production";
|
||||
|
||||
function input(
|
||||
routeId: EconomyRouteId,
|
||||
params: Readonly<Record<string, string>> = {},
|
||||
): NormalizedEconomyQueryInput {
|
||||
return {
|
||||
routeId,
|
||||
params,
|
||||
list: { search: "", pageSize: 25, offset: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
describe("Economy production query adapter", () => {
|
||||
it("maps voucher rows without exposing codes and serializes money", async () => {
|
||||
const execute = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([[{ total: 1 }], []])
|
||||
.mockResolvedValueOnce([
|
||||
[
|
||||
{
|
||||
id: 18446744073709551615n,
|
||||
title: "ABC...XYZ",
|
||||
description: "0/5 uses",
|
||||
status: "active",
|
||||
updated_at: new Date("2026-08-30T10:00:00.000Z"),
|
||||
amount: 50,
|
||||
currency: "credits",
|
||||
},
|
||||
],
|
||||
[],
|
||||
]);
|
||||
const result = await loadEconomyDatabaseQueryWith(
|
||||
input("economy.commerce.vouchers"),
|
||||
execute,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
kind: "commerce",
|
||||
total: 1,
|
||||
items: [
|
||||
{
|
||||
id: "18446744073709551615",
|
||||
title: "ABC...XYZ",
|
||||
amount: "CREDITS 50.00",
|
||||
href: "/ase-next/economy/commerce/vouchers",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain("SECRET-CODE");
|
||||
expect(execute).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("uses the empty adapter for create-only routes", async () => {
|
||||
const execute = vi.fn();
|
||||
await expect(
|
||||
loadEconomyDatabaseQueryWith(
|
||||
input("economy.commerce.shop-create"),
|
||||
execute,
|
||||
),
|
||||
).resolves.toMatchObject({ items: [], total: 0, kind: "commerce" });
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed on invalid totals returned by the dependency", async () => {
|
||||
const execute = vi.fn().mockResolvedValueOnce([[{ total: -1 }], []]);
|
||||
await expect(
|
||||
loadEconomyDatabaseQueryWith(
|
||||
input("economy.history.transactions"),
|
||||
execute,
|
||||
),
|
||||
).rejects.toThrow("invalid Economy query total");
|
||||
});
|
||||
|
||||
it("defines deterministic ordering for every list query", () => {
|
||||
for (const [routeId, definition] of Object.entries(
|
||||
ECONOMY_QUERY_DEFINITIONS,
|
||||
)) {
|
||||
if (routeId === "economy.catalog.maintenance") continue;
|
||||
expect(definition.statement, routeId).toMatch(/\bORDER BY\b/iu);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
import "server-only";
|
||||
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { type EconomyRouteId, economyRouteGroup } from "../routes";
|
||||
import type {
|
||||
EconomyQueryData,
|
||||
EconomyQueryItem,
|
||||
NormalizedEconomyQueryInput,
|
||||
} from "./catalog";
|
||||
import { serializeEconomyAmount } from "./commerce";
|
||||
|
||||
type RawRow = Readonly<Record<string, unknown>>;
|
||||
|
||||
interface QueryDefinition {
|
||||
readonly statement: string;
|
||||
readonly href: string;
|
||||
readonly appendId?: boolean;
|
||||
readonly parameterAlias?: "id" | "campaign_id";
|
||||
}
|
||||
|
||||
export const ECONOMY_QUERY_DEFINITIONS = {
|
||||
"economy.catalog.overview": {
|
||||
statement:
|
||||
"SELECT id, caption AS title, CONCAT('parent-', parent_id) AS description, CASE WHEN enabled = '1' AND visible = '1' THEN 'active' ELSE 'hidden' END AS status, NULL AS updated_at FROM catalog_pages ORDER BY order_num ASC, id ASC",
|
||||
href: "/ase-next/economy/catalog/",
|
||||
appendId: true,
|
||||
},
|
||||
"economy.catalog.detail": {
|
||||
statement:
|
||||
"SELECT id, caption AS title, CONCAT('layout-', page_layout) AS description, CASE WHEN enabled = '1' AND visible = '1' THEN 'active' ELSE 'hidden' END AS status, NULL AS updated_at FROM catalog_pages ORDER BY order_num ASC, id ASC",
|
||||
href: "/ase-next/economy/catalog/",
|
||||
appendId: true,
|
||||
},
|
||||
"economy.catalog.builder-club-detail": {
|
||||
statement:
|
||||
"SELECT id, caption AS title, CONCAT('parent-', parent_id) AS description, CASE WHEN enabled = '1' AND visible = '1' THEN 'active' ELSE 'hidden' END AS status, NULL AS updated_at FROM catalog_pages_bc ORDER BY order_num ASC, id ASC",
|
||||
href: "/ase-next/economy/catalog/builder-club/",
|
||||
appendId: true,
|
||||
},
|
||||
"economy.catalog.maintenance": {
|
||||
statement:
|
||||
"SELECT 1 AS id, 'Catalog maintenance' AS title, CONCAT(COUNT(*), ' catalog pages available') AS description, 'ready' AS status, NULL AS updated_at FROM catalog_pages",
|
||||
href: "/ase-next/economy/catalog/maintenance",
|
||||
},
|
||||
"economy.items.overview": {
|
||||
statement:
|
||||
"SELECT id, COALESCE(NULLIF(public_name, ''), item_name) AS title, item_name AS description, interaction_type AS status, NULL AS updated_at FROM items_base ORDER BY id DESC",
|
||||
href: "/ase-next/economy/items/",
|
||||
appendId: true,
|
||||
},
|
||||
"economy.items.detail": {
|
||||
statement:
|
||||
"SELECT id, COALESCE(NULLIF(public_name, ''), item_name) AS title, item_name AS description, interaction_type AS status, NULL AS updated_at FROM items_base ORDER BY id DESC",
|
||||
href: "/ase-next/economy/items/",
|
||||
appendId: true,
|
||||
},
|
||||
"economy.commerce.shop": {
|
||||
statement:
|
||||
"SELECT id, name AS title, info AS description, 'active' AS status, updated_at, costs AS amount, 'credits' AS currency FROM website_shop_articles ORDER BY position ASC, id DESC",
|
||||
href: "/ase-next/economy/commerce/shop/",
|
||||
appendId: true,
|
||||
},
|
||||
"economy.commerce.shop-detail": {
|
||||
statement:
|
||||
"SELECT id, name AS title, info AS description, 'active' AS status, updated_at, costs AS amount, 'credits' AS currency FROM website_shop_articles ORDER BY position ASC, id DESC",
|
||||
href: "/ase-next/economy/commerce/shop/",
|
||||
appendId: true,
|
||||
},
|
||||
"economy.commerce.marketplace": {
|
||||
statement:
|
||||
"SELECT id, CONCAT('Listing #', id) AS title, CONCAT('user-', user_id, ' item-', item_id) AS description, CASE state WHEN 1 THEN 'active' WHEN 2 THEN 'sold' ELSE 'cancelled' END AS status, FROM_UNIXTIME(timestamp) AS updated_at, price AS amount, 'credits' AS currency FROM marketplace_items ORDER BY timestamp DESC, id DESC",
|
||||
href: "/ase-next/economy/commerce/marketplace",
|
||||
},
|
||||
"economy.commerce.vouchers": {
|
||||
statement:
|
||||
"SELECT id, CONCAT(LEFT(code, 3), '...', RIGHT(code, 3)) AS title, CONCAT(use_count, '/', max_uses, ' uses') AS description, CASE WHEN expires_at IS NOT NULL AND expires_at < CURRENT_TIMESTAMP THEN 'expired' WHEN use_count >= max_uses THEN 'exhausted' ELSE 'active' END AS status, updated_at, amount, 'credits' AS currency FROM website_shop_vouchers ORDER BY id DESC",
|
||||
href: "/ase-next/economy/commerce/vouchers",
|
||||
},
|
||||
"economy.commerce.subscriptions": {
|
||||
statement:
|
||||
"SELECT id, CONCAT('User #', COALESCE(user_id, 0)) AS title, subscription_type AS description, CASE WHEN COALESCE(timestamp_start, 0) + COALESCE(duration, 0) > UNIX_TIMESTAMP() THEN 'active' ELSE 'expired' END AS status, FROM_UNIXTIME(COALESCE(timestamp_start, 0) + COALESCE(duration, 0)) AS updated_at FROM users_subscriptions WHERE active = 1 ORDER BY (COALESCE(timestamp_start, 0) + COALESCE(duration, 0)) ASC, id ASC",
|
||||
href: "/ase-next/economy/commerce/subscriptions",
|
||||
},
|
||||
"economy.history.transactions": {
|
||||
statement:
|
||||
"SELECT id, transaction_id AS title, description, COALESCE(status, 'unknown') AS status, updated_at, amount, currency FROM website_paypal_transactions ORDER BY created_at DESC, id DESC",
|
||||
href: "/ase-next/economy/history/transactions",
|
||||
},
|
||||
"economy.value.rare-values": {
|
||||
statement:
|
||||
"SELECT id, name AS title, CONCAT(COALESCE(credit_value, '0'), ' credits; ', COALESCE(currency_value, '0'), ' ', currency_type) AS description, currency_type AS status, updated_at FROM website_rare_values ORDER BY category_id ASC, id ASC",
|
||||
href: "/ase-next/economy/value/rare-values",
|
||||
},
|
||||
"economy.rewards.badges": {
|
||||
statement:
|
||||
"SELECT id, badge_name AS title, badge_key AS description, 'available' AS status, updated_at FROM website_badges ORDER BY badge_name ASC, id ASC",
|
||||
href: "/ase-next/economy/rewards/badges",
|
||||
},
|
||||
"economy.rewards.achievements": {
|
||||
statement:
|
||||
"SELECT id, name AS title, CONCAT(category, ' level ', level) AS description, CONCAT(reward_amount, ':', reward_type) AS status, NULL AS updated_at FROM achievements ORDER BY name ASC, level ASC, id ASC",
|
||||
href: "/ase-next/economy/rewards/achievements",
|
||||
},
|
||||
"economy.rewards.sounds": {
|
||||
statement:
|
||||
"SELECT id, name AS title, author AS description, CONCAT(length, 'ms') AS status, NULL AS updated_at FROM soundtracks ORDER BY id ASC",
|
||||
href: "/ase-next/economy/rewards/sounds",
|
||||
},
|
||||
"economy.rewards.calendar": {
|
||||
statement:
|
||||
"SELECT id, name AS title, CONCAT(total_days, ' days') AS description, CASE WHEN enabled = '1' THEN 'enabled' ELSE 'disabled' END AS status, FROM_UNIXTIME(start_timestamp) AS updated_at FROM calendar_campaigns ORDER BY id DESC",
|
||||
href: "/ase-next/economy/rewards/calendar/",
|
||||
appendId: true,
|
||||
},
|
||||
"economy.rewards.calendar-detail": {
|
||||
statement:
|
||||
"SELECT id, product_name AS title, CONCAT('item-', item_id, ' badge-', badge) AS description, CASE WHEN subscription_days > 0 THEN CONCAT('subscription-', subscription_days, 'd') ELSE 'reward' END AS status, NULL AS updated_at, campaign_id FROM calendar_rewards ORDER BY id ASC",
|
||||
href: "/ase-next/economy/rewards/calendar",
|
||||
parameterAlias: "campaign_id",
|
||||
},
|
||||
} as const satisfies Partial<Record<EconomyRouteId, QueryDefinition>>;
|
||||
|
||||
const EMPTY_ROUTES = new Set<EconomyRouteId>(["economy.commerce.shop-create"]);
|
||||
|
||||
function rows(result: unknown): readonly RawRow[] {
|
||||
if (!Array.isArray(result) || !Array.isArray(result[0])) {
|
||||
throw new Error("invalid Economy query result");
|
||||
}
|
||||
return result[0] as readonly RawRow[];
|
||||
}
|
||||
|
||||
function value(input: unknown, fallback = ""): string {
|
||||
return typeof input === "string"
|
||||
? input
|
||||
: input == null
|
||||
? fallback
|
||||
: String(input);
|
||||
}
|
||||
|
||||
function date(valueToParse: unknown): string | null {
|
||||
if (valueToParse == null) return null;
|
||||
const parsed =
|
||||
valueToParse instanceof Date
|
||||
? valueToParse
|
||||
: new Date(String(valueToParse));
|
||||
return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : null;
|
||||
}
|
||||
|
||||
function statementParts(statement: string) {
|
||||
const match = /^(.*?)(\s+ORDER BY\s+.+)$/iu.exec(statement);
|
||||
return match
|
||||
? { selection: match[1], ordering: match[2] }
|
||||
: { selection: statement, ordering: "" };
|
||||
}
|
||||
|
||||
function totalFromRows(rawRows: readonly RawRow[]): number {
|
||||
const total = Number(rawRows[0]?.total ?? 0);
|
||||
if (!Number.isSafeInteger(total) || total < 0) {
|
||||
throw new Error("invalid Economy query total");
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function mapRows(
|
||||
definition: QueryDefinition,
|
||||
rawRows: readonly RawRow[],
|
||||
): readonly EconomyQueryItem[] {
|
||||
return rawRows.map((row) => {
|
||||
const id = value(row.id);
|
||||
if (!id) throw new Error("invalid Economy identifier");
|
||||
const amount =
|
||||
row.amount == null
|
||||
? undefined
|
||||
: serializeEconomyAmount(
|
||||
row.amount as string | number,
|
||||
value(row.currency, "credits"),
|
||||
);
|
||||
return {
|
||||
id,
|
||||
title: value(row.title, "Untitled economy entry"),
|
||||
description: value(row.description) || undefined,
|
||||
status: value(row.status) || undefined,
|
||||
updatedAt: date(row.updated_at),
|
||||
amount,
|
||||
href: definition.appendId ? definition.href + id : definition.href,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function empty(input: NormalizedEconomyQueryInput): EconomyQueryData {
|
||||
return {
|
||||
kind: economyRouteGroup(input.routeId),
|
||||
items: [],
|
||||
total: 0,
|
||||
partialDependencies: [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadEconomyDatabaseQuery(
|
||||
input: NormalizedEconomyQueryInput,
|
||||
): Promise<EconomyQueryData> {
|
||||
const { db } = await import("@/lib/db");
|
||||
return loadEconomyDatabaseQueryWith(input, (query) => db.execute(query));
|
||||
}
|
||||
|
||||
export async function loadEconomyDatabaseQueryWith(
|
||||
input: NormalizedEconomyQueryInput,
|
||||
execute: (query: SQL) => Promise<unknown>,
|
||||
): Promise<EconomyQueryData> {
|
||||
if (EMPTY_ROUTES.has(input.routeId)) return empty(input);
|
||||
const definition = (
|
||||
ECONOMY_QUERY_DEFINITIONS as Partial<
|
||||
Record<EconomyRouteId, QueryDefinition>
|
||||
>
|
||||
)[input.routeId];
|
||||
if (!definition) throw new Error("missing Economy query adapter");
|
||||
const { sql } = await import("drizzle-orm");
|
||||
const { selection, ordering } = statementParts(definition.statement);
|
||||
const filters: SQL[] = [];
|
||||
if (input.list.search) {
|
||||
const pattern = `%${input.list.search.toLocaleLowerCase()}%`;
|
||||
filters.push(
|
||||
/\bAS\s+description\b/iu.test(selection) ||
|
||||
/\bdescription\b/iu.test(selection)
|
||||
? sql`(LOWER(COALESCE(title, '')) LIKE ${pattern} OR LOWER(COALESCE(description, '')) LIKE ${pattern})`
|
||||
: sql`LOWER(COALESCE(title, '')) LIKE ${pattern}`,
|
||||
);
|
||||
}
|
||||
if (input.params.id) {
|
||||
const alias = definition.parameterAlias ?? "id";
|
||||
filters.push(sql`CAST(${sql.raw(alias)} AS CHAR) = ${input.params.id}`);
|
||||
}
|
||||
const filtered = filters.length
|
||||
? sql`${sql.raw(selection)} HAVING ${sql.join(filters, sql` AND `)}`
|
||||
: sql.raw(selection);
|
||||
const total = totalFromRows(
|
||||
rows(
|
||||
await execute(
|
||||
sql`SELECT COUNT(*) AS total FROM (${filtered}) AS economy_rows`,
|
||||
),
|
||||
),
|
||||
);
|
||||
const items = mapRows(
|
||||
definition,
|
||||
rows(
|
||||
await execute(
|
||||
sql`${filtered} ${sql.raw(ordering)} LIMIT ${input.list.pageSize} OFFSET ${input.list.offset}`,
|
||||
),
|
||||
),
|
||||
);
|
||||
return {
|
||||
kind: economyRouteGroup(input.routeId),
|
||||
items,
|
||||
total,
|
||||
partialDependencies: [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||
import { ECONOMY_ROUTE_IDS } from "../routes";
|
||||
import { createEconomyQuery, type EconomyQueryData } from "./catalog";
|
||||
import { serializeEconomyAmount } from "./commerce";
|
||||
import { serializeEconomyValue } from "./value";
|
||||
|
||||
function context(granted: readonly string[]): HousekeepingCapabilityContext {
|
||||
const permissions = new Set(granted);
|
||||
return {
|
||||
actor: { id: 42, username: "operator", rank: 7 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => permissions.has(slug),
|
||||
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
||||
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
||||
};
|
||||
}
|
||||
|
||||
describe("Economy query adapters", () => {
|
||||
it("fails closed before invoking an unauthorized adapter", async () => {
|
||||
const load = vi.fn();
|
||||
const query = createEconomyQuery({ load });
|
||||
const result = await query.run(context([]), {
|
||||
routeId: "economy.catalog.overview",
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "FORBIDDEN" },
|
||||
});
|
||||
expect(load).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes stable pagination and preserves BIGINT identifiers", async () => {
|
||||
const load = vi.fn(
|
||||
async (): Promise<EconomyQueryData> => ({
|
||||
kind: "history",
|
||||
items: [
|
||||
{
|
||||
id: "18446744073709551615",
|
||||
title: "Completed order",
|
||||
amount: "EUR 12.50",
|
||||
href: "/ase-next/economy/history/transactions",
|
||||
},
|
||||
],
|
||||
total: 501,
|
||||
partialDependencies: [],
|
||||
}),
|
||||
);
|
||||
const query = createEconomyQuery({ load });
|
||||
const result = await query.run(context([PERMS.SHOP_VIEW]), {
|
||||
routeId: "economy.history.transactions",
|
||||
list: { search: " paid ", pageSize: 999, offset: 999_999 },
|
||||
});
|
||||
expect(load).toHaveBeenCalledWith({
|
||||
routeId: "economy.history.transactions",
|
||||
params: {},
|
||||
list: { search: "paid", pageSize: 100, offset: 100_000 },
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
data: { items: [{ id: "18446744073709551615" }], total: 501 },
|
||||
});
|
||||
});
|
||||
|
||||
it("serializes money and rare values without locale-dependent rounding", () => {
|
||||
expect(serializeEconomyAmount(12.5, "eur")).toBe("EUR 12.50");
|
||||
expect(serializeEconomyAmount("9007199254740993", "usd")).toBe(
|
||||
"USD 9007199254740993",
|
||||
);
|
||||
expect(serializeEconomyValue("1250.50", "diamonds")).toEqual({
|
||||
value: "1250.50",
|
||||
currency: "diamonds",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects fabricated partial dependencies and unsafe hrefs", async () => {
|
||||
const query = createEconomyQuery({
|
||||
load: async () => ({
|
||||
kind: "catalog",
|
||||
items: [
|
||||
{ id: "1", title: "Unsafe", href: "/ase-next/economy/%2e%2e/system" },
|
||||
],
|
||||
total: 1,
|
||||
partialDependencies: ["imaginary"],
|
||||
}),
|
||||
});
|
||||
expect(
|
||||
await query.run(context([PERMS.CATALOG_VIEW]), {
|
||||
routeId: "economy.catalog.overview",
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
||||
});
|
||||
});
|
||||
|
||||
it("has an authorized adapter path for every Economy route", async () => {
|
||||
const load = vi.fn(async (input) => ({
|
||||
kind: input.routeId.split(".")[1] as EconomyQueryData["kind"],
|
||||
items: [],
|
||||
total: 0,
|
||||
partialDependencies: [],
|
||||
}));
|
||||
const query = createEconomyQuery({ load });
|
||||
for (const routeId of ECONOMY_ROUTE_IDS) {
|
||||
const result = await query.run(context(Object.values(PERMS)), {
|
||||
routeId,
|
||||
});
|
||||
expect(result.ok, routeId).toBe(true);
|
||||
}
|
||||
expect(load).toHaveBeenCalledTimes(ECONOMY_ROUTE_IDS.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { EconomyQueryData, NormalizedEconomyQueryInput } from "./catalog";
|
||||
|
||||
export function serializeEconomyValue(value: string, currency: string) {
|
||||
return {
|
||||
value: String(value).normalize("NFC").trim().slice(0, 255),
|
||||
currency: String(currency).normalize("NFC").trim().slice(0, 255),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadEconomyValueQuery(
|
||||
input: NormalizedEconomyQueryInput,
|
||||
): Promise<EconomyQueryData> {
|
||||
const { loadEconomyDatabaseQuery } = await import("./economy-production");
|
||||
return loadEconomyDatabaseQuery(input);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { HousekeepingRouteHandler } from "../../route-handlers";
|
||||
import { renderEconomyCatalogPage } from "./pages/catalog";
|
||||
import { renderEconomyCommercePage } from "./pages/commerce";
|
||||
import { renderEconomyHistoryPage } from "./pages/history";
|
||||
import { renderEconomyItemsPage } from "./pages/items";
|
||||
import { renderEconomyRewardsPage } from "./pages/rewards";
|
||||
import { renderEconomyValuePage } from "./pages/value";
|
||||
import {
|
||||
ECONOMY_ROUTE_IDS,
|
||||
type EconomyRouteId,
|
||||
economyRouteGroup,
|
||||
} from "./routes";
|
||||
|
||||
function rendererFor(
|
||||
routeId: EconomyRouteId,
|
||||
): HousekeepingRouteHandler["render"] {
|
||||
const group = economyRouteGroup(routeId);
|
||||
if (group === "catalog") return renderEconomyCatalogPage;
|
||||
if (group === "items") return renderEconomyItemsPage;
|
||||
if (group === "commerce") return renderEconomyCommercePage;
|
||||
if (group === "history") return renderEconomyHistoryPage;
|
||||
if (group === "value") return renderEconomyValuePage;
|
||||
return renderEconomyRewardsPage;
|
||||
}
|
||||
|
||||
export const ECONOMY_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[] =
|
||||
Object.freeze(
|
||||
ECONOMY_ROUTE_IDS.map((routeId) =>
|
||||
Object.freeze({ routeId, render: rendererFor(routeId) }),
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { HOUSEKEEPING_MANIFESTS } from "../../manifests";
|
||||
import { economyMigrationEntries } from "../../migration/economy";
|
||||
import { HOUSEKEEPING_ROUTE_HANDLERS } from "../../route-handlers";
|
||||
import { economyManifest } from "./manifest";
|
||||
import {
|
||||
ECONOMY_ROUTE_GROUPS,
|
||||
ECONOMY_ROUTE_IDS,
|
||||
ECONOMY_ROUTES,
|
||||
type EconomyRouteId,
|
||||
economyRouteGroup,
|
||||
} from "./routes";
|
||||
|
||||
const expected = [
|
||||
["economy.catalog.overview", "/ase-next/economy/catalog", "catalog"],
|
||||
["economy.catalog.detail", "/ase-next/economy/catalog/:id", "catalog"],
|
||||
[
|
||||
"economy.catalog.builder-club-detail",
|
||||
"/ase-next/economy/catalog/builder-club/:id",
|
||||
"catalog",
|
||||
],
|
||||
[
|
||||
"economy.catalog.maintenance",
|
||||
"/ase-next/economy/catalog/maintenance",
|
||||
"catalog",
|
||||
],
|
||||
["economy.items.overview", "/ase-next/economy/items", "items"],
|
||||
["economy.items.detail", "/ase-next/economy/items/:id", "items"],
|
||||
["economy.commerce.shop", "/ase-next/economy/commerce/shop", "commerce"],
|
||||
[
|
||||
"economy.commerce.shop-create",
|
||||
"/ase-next/economy/commerce/shop/new",
|
||||
"commerce",
|
||||
],
|
||||
[
|
||||
"economy.commerce.shop-detail",
|
||||
"/ase-next/economy/commerce/shop/:id",
|
||||
"commerce",
|
||||
],
|
||||
[
|
||||
"economy.commerce.marketplace",
|
||||
"/ase-next/economy/commerce/marketplace",
|
||||
"commerce",
|
||||
],
|
||||
["economy.commerce.vouchers", "/ase-next/economy/commerce/vouchers", "commerce"],
|
||||
[
|
||||
"economy.commerce.subscriptions",
|
||||
"/ase-next/economy/commerce/subscriptions",
|
||||
"commerce",
|
||||
],
|
||||
[
|
||||
"economy.history.transactions",
|
||||
"/ase-next/economy/history/transactions",
|
||||
"history",
|
||||
],
|
||||
["economy.value.rare-values", "/ase-next/economy/value/rare-values", "value"],
|
||||
["economy.rewards.badges", "/ase-next/economy/rewards/badges", "rewards"],
|
||||
[
|
||||
"economy.rewards.achievements",
|
||||
"/ase-next/economy/rewards/achievements",
|
||||
"rewards",
|
||||
],
|
||||
["economy.rewards.sounds", "/ase-next/economy/rewards/sounds", "rewards"],
|
||||
["economy.rewards.calendar", "/ase-next/economy/rewards/calendar", "rewards"],
|
||||
[
|
||||
"economy.rewards.calendar-detail",
|
||||
"/ase-next/economy/rewards/calendar/:id",
|
||||
"rewards",
|
||||
],
|
||||
] as const;
|
||||
|
||||
describe("Economy routes", () => {
|
||||
it("declares the six exact route groups", () => {
|
||||
expect(ECONOMY_ROUTE_GROUPS).toEqual([
|
||||
"catalog",
|
||||
"items",
|
||||
"commerce",
|
||||
"history",
|
||||
"value",
|
||||
"rewards",
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps all 20 matrix rows into 19 canonical workflows", () => {
|
||||
expect(
|
||||
ECONOMY_ROUTES.map((route) => [
|
||||
route.id,
|
||||
route.href,
|
||||
economyRouteGroup(route.id as EconomyRouteId),
|
||||
]),
|
||||
).toEqual(expected);
|
||||
expect(ECONOMY_ROUTE_IDS).toEqual(expected.map(([id]) => id));
|
||||
expect(
|
||||
[...new Set(ECONOMY_ROUTES.map((route) => route.href))].sort(),
|
||||
).toEqual(
|
||||
[
|
||||
...new Set(
|
||||
economyMigrationEntries
|
||||
.filter((entry) => entry.targetPath !== null)
|
||||
.map((entry) => entry.targetPath),
|
||||
),
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps manifest routes and real handlers in exact equality", () => {
|
||||
expect(economyManifest.routes).toBe(ECONOMY_ROUTES);
|
||||
expect(economyManifest.routes.map((route) => route.id)).toEqual(
|
||||
HOUSEKEEPING_ROUTE_HANDLERS.filter((handler) =>
|
||||
handler.routeId.startsWith("economy."),
|
||||
).map((handler) => handler.routeId),
|
||||
);
|
||||
expect(
|
||||
HOUSEKEEPING_MANIFESTS.flatMap((manifest) =>
|
||||
manifest.routes.map((route) => route.id),
|
||||
),
|
||||
).toEqual(HOUSEKEEPING_ROUTE_HANDLERS.map((handler) => handler.routeId));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import {
|
||||
anyCapability,
|
||||
type CanonicalHousekeepingHref,
|
||||
type HousekeepingRouteDefinition,
|
||||
} from "../../foundation/contracts";
|
||||
|
||||
export const ECONOMY_ROUTE_GROUPS = [
|
||||
"catalog",
|
||||
"items",
|
||||
"commerce",
|
||||
"history",
|
||||
"value",
|
||||
"rewards",
|
||||
] as const;
|
||||
export type EconomyRouteGroup = (typeof ECONOMY_ROUTE_GROUPS)[number];
|
||||
|
||||
export const ECONOMY_ROUTE_IDS = [
|
||||
"economy.catalog.overview",
|
||||
"economy.catalog.detail",
|
||||
"economy.catalog.builder-club-detail",
|
||||
"economy.catalog.maintenance",
|
||||
"economy.items.overview",
|
||||
"economy.items.detail",
|
||||
"economy.commerce.shop",
|
||||
"economy.commerce.shop-create",
|
||||
"economy.commerce.shop-detail",
|
||||
"economy.commerce.marketplace",
|
||||
"economy.commerce.vouchers",
|
||||
"economy.commerce.subscriptions",
|
||||
"economy.history.transactions",
|
||||
"economy.value.rare-values",
|
||||
"economy.rewards.badges",
|
||||
"economy.rewards.achievements",
|
||||
"economy.rewards.sounds",
|
||||
"economy.rewards.calendar",
|
||||
"economy.rewards.calendar-detail",
|
||||
] as const;
|
||||
export type EconomyRouteId = (typeof ECONOMY_ROUTE_IDS)[number];
|
||||
|
||||
function economyRoute(
|
||||
id: EconomyRouteId,
|
||||
href: CanonicalHousekeepingHref,
|
||||
permissions: readonly string[],
|
||||
): HousekeepingRouteDefinition {
|
||||
return {
|
||||
id,
|
||||
labelKey: `pages.housekeeping.routes.${id}`,
|
||||
href,
|
||||
capability: anyCapability(...permissions),
|
||||
};
|
||||
}
|
||||
|
||||
export const ECONOMY_ROUTES = [
|
||||
economyRoute("economy.catalog.overview", "/ase-next/economy/catalog", [
|
||||
PERMS.CATALOG_VIEW,
|
||||
]),
|
||||
economyRoute("economy.catalog.detail", "/ase-next/economy/catalog/:id", [
|
||||
PERMS.CATALOG_VIEW,
|
||||
]),
|
||||
economyRoute(
|
||||
"economy.catalog.builder-club-detail",
|
||||
"/ase-next/economy/catalog/builder-club/:id",
|
||||
[PERMS.CATALOG_VIEW],
|
||||
),
|
||||
economyRoute(
|
||||
"economy.catalog.maintenance",
|
||||
"/ase-next/economy/catalog/maintenance",
|
||||
[PERMS.CATALOG_VIEW],
|
||||
),
|
||||
economyRoute("economy.items.overview", "/ase-next/economy/items", [
|
||||
PERMS.CATALOG_VIEW,
|
||||
]),
|
||||
economyRoute("economy.items.detail", "/ase-next/economy/items/:id", [
|
||||
PERMS.CATALOG_VIEW,
|
||||
]),
|
||||
economyRoute("economy.commerce.shop", "/ase-next/economy/commerce/shop", [
|
||||
PERMS.SHOP_VIEW,
|
||||
]),
|
||||
economyRoute(
|
||||
"economy.commerce.shop-create",
|
||||
"/ase-next/economy/commerce/shop/new",
|
||||
[PERMS.SHOP_EDIT],
|
||||
),
|
||||
economyRoute(
|
||||
"economy.commerce.shop-detail",
|
||||
"/ase-next/economy/commerce/shop/:id",
|
||||
[PERMS.SHOP_VIEW],
|
||||
),
|
||||
economyRoute(
|
||||
"economy.commerce.marketplace",
|
||||
"/ase-next/economy/commerce/marketplace",
|
||||
[PERMS.SHOP_VIEW],
|
||||
),
|
||||
economyRoute("economy.commerce.vouchers", "/ase-next/economy/commerce/vouchers", [
|
||||
PERMS.SHOP_VIEW,
|
||||
]),
|
||||
economyRoute(
|
||||
"economy.commerce.subscriptions",
|
||||
"/ase-next/economy/commerce/subscriptions",
|
||||
[PERMS.SHOP_VIEW],
|
||||
),
|
||||
economyRoute(
|
||||
"economy.history.transactions",
|
||||
"/ase-next/economy/history/transactions",
|
||||
[PERMS.SHOP_VIEW],
|
||||
),
|
||||
economyRoute("economy.value.rare-values", "/ase-next/economy/value/rare-values", [
|
||||
PERMS.SHOP_VIEW,
|
||||
]),
|
||||
economyRoute("economy.rewards.badges", "/ase-next/economy/rewards/badges", [
|
||||
PERMS.CATALOG_VIEW,
|
||||
]),
|
||||
economyRoute(
|
||||
"economy.rewards.achievements",
|
||||
"/ase-next/economy/rewards/achievements",
|
||||
[PERMS.CATALOG_VIEW],
|
||||
),
|
||||
economyRoute("economy.rewards.sounds", "/ase-next/economy/rewards/sounds", [
|
||||
PERMS.CATALOG_VIEW,
|
||||
]),
|
||||
economyRoute("economy.rewards.calendar", "/ase-next/economy/rewards/calendar", [
|
||||
PERMS.SHOP_VIEW,
|
||||
]),
|
||||
economyRoute(
|
||||
"economy.rewards.calendar-detail",
|
||||
"/ase-next/economy/rewards/calendar/:id",
|
||||
[PERMS.SHOP_VIEW],
|
||||
),
|
||||
] as const satisfies readonly HousekeepingRouteDefinition[];
|
||||
|
||||
const groups = new Map<EconomyRouteId, EconomyRouteGroup>(
|
||||
ECONOMY_ROUTE_IDS.map((routeId) => [
|
||||
routeId,
|
||||
routeId.split(".")[1] as EconomyRouteGroup,
|
||||
]),
|
||||
);
|
||||
|
||||
export function economyRouteGroup(routeId: EconomyRouteId): EconomyRouteGroup {
|
||||
const group = groups.get(routeId);
|
||||
if (!group) throw new Error(`unknown Economy route: ${routeId}`);
|
||||
return group;
|
||||
}
|
||||
|
||||
export function economyRouteById(routeId: EconomyRouteId) {
|
||||
const route = ECONOMY_ROUTES.find((candidate) => candidate.id === routeId);
|
||||
if (!route) throw new Error(`unknown Economy route: ${routeId}`);
|
||||
return route;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import "server-only";
|
||||
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import {
|
||||
anyCapability,
|
||||
type HousekeepingCapabilityContext,
|
||||
} from "../../foundation/contracts";
|
||||
import { economyQuery } from "./queries/catalog";
|
||||
import type { EconomySearchCandidate } from "./search";
|
||||
|
||||
type EconomySearchKind = "catalog-pages" | "items" | "transactions";
|
||||
|
||||
const ROUTES = {
|
||||
"catalog-pages": [
|
||||
"economy.catalog.overview",
|
||||
anyCapability(PERMS.CATALOG_VIEW),
|
||||
],
|
||||
items: ["economy.items.overview", anyCapability(PERMS.CATALOG_VIEW)],
|
||||
transactions: [
|
||||
"economy.history.transactions",
|
||||
anyCapability(PERMS.SHOP_VIEW),
|
||||
],
|
||||
} as const;
|
||||
|
||||
export async function loadEconomySearchCandidates(
|
||||
kind: EconomySearchKind,
|
||||
context: HousekeepingCapabilityContext,
|
||||
term: string,
|
||||
limit: number,
|
||||
query: Pick<typeof economyQuery, "run"> = economyQuery,
|
||||
): Promise<readonly EconomySearchCandidate[]> {
|
||||
const [routeId, capability] = ROUTES[kind];
|
||||
const result = await query.run(context, {
|
||||
routeId,
|
||||
list: { search: term, pageSize: limit, offset: 0 },
|
||||
});
|
||||
if (!result.ok) throw new Error("Economy search query unavailable");
|
||||
return result.data.items.flatMap((item) =>
|
||||
item.href
|
||||
? [
|
||||
{
|
||||
id: `${routeId}:${item.id}`,
|
||||
title: item.title,
|
||||
description: item.description ?? item.status ?? item.amount,
|
||||
href: item.href,
|
||||
capability,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
Loaded 100 of 402 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user