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", "navigation.update", "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> | null; readonly after: Readonly> | null; readonly output?: Readonly>; 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; } export interface ContentMutationService { execute( invocation: ContentMutationInvocation, operation: ContentMutationOperation, input: unknown, ): Promise>; } export class ContentMutationFailure extends Error { constructor( readonly code: HousekeepingErrorCode, readonly messageKey: string, readonly fieldErrors?: Readonly>, ) { 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, "navigation.update": PERMS.SETTINGS_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); export function contentMutationCapability(operation: ContentMutationOperation) { return anyCapability(OPERATION_PERMISSION[operation]); } export function createContentMutationService( adapter: ContentMutationAdapter, resolveCapabilityContext: () => Promise, ): 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(); }, );