metis-desk/modules/companies/src/api/domain/access/access-workspace.aggregate.ts
2026-10-10 12:01:58 +02:00

127 lines
3.6 KiB
TypeScript

import type { UniqueID } from "@repo/rdx-ddd";
import { AggregateRoot } from "@repo/rdx-ddd";
import type { Maybe } from "@repo/rdx-utils";
import { Result } from "@repo/rdx-utils";
import type { Person } from "../person.entity";
import { AccessError } from "./access-error";
import type { AccessItem, AuditContext } from "./access-item.entity";
import {
AccessPolicy,
accessConflict,
checkCatalog,
checkConfiguration,
checkRelations,
} from "./access-policy";
import type { AccessValues } from "./access-values.vo";
export type AccessContext = Readonly<{
catalog: readonly AccessItem[];
people: readonly Person[];
today: string;
}>;
export class AccessWorkspace extends AggregateRoot<{
companyId: Maybe<UniqueID>;
items: readonly AccessItem[];
}> {
protected constructor(
props: { companyId: Maybe<UniqueID>; items: readonly AccessItem[] },
id: UniqueID
) {
super({ ...props, items: Object.freeze([...props.items]) }, id);
}
static create(companyId: Maybe<UniqueID>, id: UniqueID) {
return new AccessWorkspace({ companyId, items: [] }, id);
}
static rehydrate(companyId: Maybe<UniqueID>, items: readonly AccessItem[], id: UniqueID) {
return new AccessWorkspace({ companyId, items }, id);
}
get companyId() {
return this.props.companyId;
}
get items() {
return this.props.items;
}
save(
candidate: AccessItem,
context: AccessContext,
previous?: AccessItem
): Result<AccessWorkspace, AccessError> {
const value = candidate.values.data;
if (
this.companyId.getOrUndefined()?.toPrimitive() !==
candidate.companyId.getOrUndefined()?.toPrimitive()
) {
return Result.fail(new AccessError("SCOPE_MISMATCH"));
}
const global = ["system", "resource_type", "permission", "template"];
if (!(this.companyId.getOrUndefined() || global.includes(value.kind))) {
return Result.fail(new AccessError("SCOPE_MISMATCH"));
}
if (value.kind === "template" && this.companyId.getOrUndefined()) {
return Result.fail(new AccessError("SCOPE_MISMATCH"));
}
const all = [...context.catalog, ...this.items.filter((item) => !item.id.equals(candidate.id))];
const isNewOrReopen =
!previous ||
("endDate" in value &&
value.endDate === null &&
"endDate" in previous.values.data &&
previous.values.data.endDate !== null);
const policy = new AccessPolicy(this.companyId, all, context, isNewOrReopen);
const failure =
checkCatalog(value, policy) ??
checkRelations(value, policy) ??
checkConfiguration(value, previous);
if (failure) {
return failure;
}
const duplicates = all.some((other) => accessConflict(candidate, other, all));
if (duplicates) {
return Result.fail(new AccessError("ACCESS_CONFLICT"));
}
if (previous === candidate) {
return Result.ok(this);
}
return Result.ok(
new AccessWorkspace(
{
companyId: this.companyId,
items: previous
? this.items.map((item) => (item.id.equals(candidate.id) ? candidate : item))
: [...this.items, candidate],
},
this.id
)
);
}
update(
id: UniqueID,
values: AccessValues,
audit: AuditContext,
context: AccessContext
): Result<AccessWorkspace, AccessError> {
const previous = this.items.find((item) => item.id.equals(id));
if (!previous) {
return Result.fail(new AccessError("ACCESS_NOT_FOUND"));
}
const candidate = previous.update(values, audit);
return candidate.isSuccess ? this.save(candidate.data, context, previous) : candidate;
}
}