209 lines
5.8 KiB
TypeScript
209 lines
5.8 KiB
TypeScript
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<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,
|
|
"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<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();
|
|
},
|
|
);
|