2026-10-05 11:13:51 +00:00
|
|
|
import { Name, UniqueID } from "@repo/rdx-ddd";
|
|
|
|
|
import { Result } from "@repo/rdx-utils";
|
|
|
|
|
import { z } from "zod/v4";
|
|
|
|
|
|
|
|
|
|
import { ResourceCatalogError } from "../resource-definition";
|
|
|
|
|
import type {
|
|
|
|
|
ResourceCharacteristic,
|
|
|
|
|
ResourceInput,
|
|
|
|
|
ResourceSpecificationData,
|
|
|
|
|
} from "../resource-values";
|
|
|
|
|
import { ResourceInputSchema, ResourceSpecificationSchema } from "../resource-values";
|
2026-10-09 18:03:56 +00:00
|
|
|
import {
|
|
|
|
|
freezeTemplateSections,
|
|
|
|
|
sameTemplateSections,
|
|
|
|
|
validTemplateSections,
|
|
|
|
|
} from "../template-sections";
|
2026-10-05 11:13:51 +00:00
|
|
|
|
|
|
|
|
export class ResourceSpecification {
|
|
|
|
|
private constructor(
|
|
|
|
|
private readonly data: ResourceSpecificationData,
|
|
|
|
|
public readonly templateId: UniqueID,
|
|
|
|
|
public readonly name: Name
|
|
|
|
|
) {
|
|
|
|
|
Object.freeze(this);
|
|
|
|
|
}
|
2026-10-10 10:01:58 +00:00
|
|
|
|
2026-10-05 11:13:51 +00:00
|
|
|
toPrimitive() {
|
|
|
|
|
return this.data;
|
|
|
|
|
}
|
2026-10-10 10:01:58 +00:00
|
|
|
|
2026-10-05 11:13:51 +00:00
|
|
|
static create(
|
|
|
|
|
input: ResourceSpecificationData
|
|
|
|
|
): Result<ResourceSpecification, ResourceCatalogError> {
|
|
|
|
|
const parsed = ResourceSpecificationSchema.safeParse(input);
|
2026-10-10 10:01:58 +00:00
|
|
|
|
2026-10-05 11:13:51 +00:00
|
|
|
if (
|
2026-10-09 18:03:56 +00:00
|
|
|
!(parsed.success && validTemplateSections(input.sections ?? [], input.fields)) ||
|
2026-10-05 11:13:51 +00:00
|
|
|
new Set(input.fields.map((field) => field.fieldId.toLowerCase())).size !== input.fields.length
|
|
|
|
|
) {
|
|
|
|
|
return Result.fail(new ResourceCatalogError("INVALID_REQUEST"));
|
|
|
|
|
}
|
|
|
|
|
const template = UniqueID.create(input.templateId);
|
|
|
|
|
const name = Name.create(input.templateName, 100);
|
2026-10-10 10:01:58 +00:00
|
|
|
|
2026-10-05 11:13:51 +00:00
|
|
|
if (!(template.isSuccess && name.isSuccess)) {
|
|
|
|
|
return Result.fail(new ResourceCatalogError("INVALID_REQUEST"));
|
|
|
|
|
}
|
|
|
|
|
const frozenFields = input.fields.map((field) => {
|
|
|
|
|
const choices = [...field.choices];
|
2026-10-10 10:01:58 +00:00
|
|
|
|
2026-10-05 11:13:51 +00:00
|
|
|
Object.freeze(choices);
|
2026-10-10 10:01:58 +00:00
|
|
|
|
2026-10-09 18:03:56 +00:00
|
|
|
return Object.freeze({
|
|
|
|
|
...field,
|
|
|
|
|
fieldId: field.fieldId.toLowerCase(),
|
|
|
|
|
choices,
|
|
|
|
|
...(field.sectionId ? { sectionId: field.sectionId.toLowerCase() } : {}),
|
|
|
|
|
});
|
2026-10-05 11:13:51 +00:00
|
|
|
});
|
2026-10-10 10:01:58 +00:00
|
|
|
|
2026-10-05 11:13:51 +00:00
|
|
|
Object.freeze(frozenFields);
|
2026-10-10 10:01:58 +00:00
|
|
|
|
2026-10-05 11:13:51 +00:00
|
|
|
return Result.ok(
|
|
|
|
|
new ResourceSpecification(
|
|
|
|
|
Object.freeze({
|
|
|
|
|
templateId: template.data.toPrimitive(),
|
|
|
|
|
templateName: name.data.toPrimitive(),
|
|
|
|
|
fields: frozenFields,
|
2026-10-09 18:03:56 +00:00
|
|
|
...(input.sections ? { sections: freezeTemplateSections(input.sections) } : {}),
|
2026-10-05 11:13:51 +00:00
|
|
|
}),
|
|
|
|
|
template.data,
|
|
|
|
|
name.data
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-10-10 10:01:58 +00:00
|
|
|
|
2026-10-05 11:13:51 +00:00
|
|
|
equals(other: ResourceSpecification | undefined) {
|
|
|
|
|
if (!(other instanceof ResourceSpecification)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
const first = this.toPrimitive();
|
|
|
|
|
const second = other.toPrimitive();
|
2026-10-10 10:01:58 +00:00
|
|
|
|
2026-10-05 11:13:51 +00:00
|
|
|
return (
|
|
|
|
|
first.templateId === second.templateId &&
|
|
|
|
|
first.templateName === second.templateName &&
|
2026-10-09 18:03:56 +00:00
|
|
|
sameTemplateSections(first.sections ?? [], second.sections ?? []) &&
|
2026-10-05 11:13:51 +00:00
|
|
|
first.fields.length === second.fields.length &&
|
|
|
|
|
first.fields.every((field, index) => {
|
|
|
|
|
const candidate = second.fields[index];
|
2026-10-10 10:01:58 +00:00
|
|
|
|
2026-10-05 11:13:51 +00:00
|
|
|
return (
|
|
|
|
|
candidate !== undefined &&
|
|
|
|
|
field.fieldId === candidate.fieldId &&
|
|
|
|
|
field.name === candidate.name &&
|
|
|
|
|
field.type === candidate.type &&
|
|
|
|
|
field.unit === candidate.unit &&
|
|
|
|
|
field.required === candidate.required &&
|
2026-10-09 18:03:56 +00:00
|
|
|
field.sectionId === candidate.sectionId &&
|
2026-10-05 11:13:51 +00:00
|
|
|
field.choices.length === candidate.choices.length &&
|
|
|
|
|
field.choices.every((choice, position) => choice === candidate.choices[position])
|
|
|
|
|
);
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-10 10:01:58 +00:00
|
|
|
|
2026-10-05 11:13:51 +00:00
|
|
|
function validCharacteristic(field: ResourceCharacteristic, value: unknown) {
|
|
|
|
|
switch (field.type) {
|
|
|
|
|
case "text":
|
|
|
|
|
return typeof value === "string";
|
|
|
|
|
case "number":
|
|
|
|
|
return typeof value === "number" && Number.isFinite(value);
|
|
|
|
|
case "boolean":
|
|
|
|
|
return typeof value === "boolean";
|
|
|
|
|
case "choice":
|
|
|
|
|
return typeof value === "string" && field.choices.includes(value);
|
|
|
|
|
case "date":
|
|
|
|
|
return z.iso.date().safeParse(value).success;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-10 10:01:58 +00:00
|
|
|
|
2026-10-05 11:13:51 +00:00
|
|
|
export class ResourceValues {
|
|
|
|
|
private constructor(
|
|
|
|
|
private readonly data: ResourceInput,
|
|
|
|
|
public readonly name: Name
|
|
|
|
|
) {
|
|
|
|
|
Object.freeze(this);
|
|
|
|
|
}
|
2026-10-10 10:01:58 +00:00
|
|
|
|
2026-10-05 11:13:51 +00:00
|
|
|
toPrimitive() {
|
|
|
|
|
return this.data;
|
|
|
|
|
}
|
2026-10-10 10:01:58 +00:00
|
|
|
|
2026-10-05 11:13:51 +00:00
|
|
|
static create(
|
|
|
|
|
input: ResourceInput,
|
|
|
|
|
specification: ResourceSpecification
|
|
|
|
|
): Result<ResourceValues, ResourceCatalogError> {
|
|
|
|
|
const parsed = ResourceInputSchema.safeParse(input);
|
|
|
|
|
const name = Name.create(input.name, 100);
|
2026-10-10 10:01:58 +00:00
|
|
|
|
2026-10-05 11:13:51 +00:00
|
|
|
if (
|
|
|
|
|
!(parsed.success && name.isSuccess) ||
|
|
|
|
|
input.templateId.toLowerCase() !== specification.toPrimitive().templateId
|
|
|
|
|
) {
|
|
|
|
|
return Result.fail(new ResourceCatalogError("INVALID_REQUEST"));
|
|
|
|
|
}
|
|
|
|
|
const data = parsed.data;
|
|
|
|
|
const parsedCharacteristics = normalizeCharacteristics(data.characteristics, specification);
|
2026-10-10 10:01:58 +00:00
|
|
|
|
2026-10-05 11:13:51 +00:00
|
|
|
if (!parsedCharacteristics.isSuccess) {
|
|
|
|
|
return parsedCharacteristics;
|
|
|
|
|
}
|
|
|
|
|
const characteristics = parsedCharacteristics.data;
|
2026-10-05 15:01:32 +00:00
|
|
|
const integration = data.integration;
|
|
|
|
|
const integrationError = validateIntegration(data, characteristics, specification);
|
2026-10-10 10:01:58 +00:00
|
|
|
|
2026-10-05 15:01:32 +00:00
|
|
|
if (integrationError) {
|
|
|
|
|
return Result.fail(new ResourceCatalogError(integrationError));
|
|
|
|
|
}
|
2026-10-10 10:01:58 +00:00
|
|
|
|
2026-10-05 11:13:51 +00:00
|
|
|
return Result.ok(
|
|
|
|
|
new ResourceValues(
|
|
|
|
|
Object.freeze({
|
|
|
|
|
...data,
|
|
|
|
|
name: name.data.toPrimitive(),
|
|
|
|
|
templateId: data.templateId.toLowerCase(),
|
|
|
|
|
notes: data.notes.trim(),
|
|
|
|
|
characteristics: Object.freeze(characteristics),
|
2026-10-05 15:01:32 +00:00
|
|
|
...(integration ? { integration: Object.freeze({ ...integration }) } : {}),
|
2026-10-05 11:13:51 +00:00
|
|
|
}),
|
|
|
|
|
name.data
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-10-10 10:01:58 +00:00
|
|
|
|
2026-10-05 11:13:51 +00:00
|
|
|
equals(other: ResourceValues | undefined) {
|
|
|
|
|
if (!(other instanceof ResourceValues)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
const first = this.toPrimitive();
|
|
|
|
|
const second = other.toPrimitive();
|
2026-10-10 10:01:58 +00:00
|
|
|
|
2026-10-05 11:13:51 +00:00
|
|
|
return (
|
|
|
|
|
first.name === second.name &&
|
|
|
|
|
first.templateId === second.templateId &&
|
|
|
|
|
first.active === second.active &&
|
|
|
|
|
first.notes === second.notes &&
|
2026-10-05 15:01:32 +00:00
|
|
|
sameIntegration(first.integration, second.integration) &&
|
2026-10-05 11:13:51 +00:00
|
|
|
Object.keys(first.characteristics).length === Object.keys(second.characteristics).length &&
|
|
|
|
|
Object.entries(first.characteristics).every(
|
|
|
|
|
([key, value]) => value === second.characteristics[key]
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeCharacteristics(
|
|
|
|
|
input: ResourceInput["characteristics"],
|
|
|
|
|
specification: ResourceSpecification
|
|
|
|
|
): Result<ResourceInput["characteristics"], ResourceCatalogError> {
|
|
|
|
|
const characteristics: ResourceInput["characteristics"] = {};
|
2026-10-10 10:01:58 +00:00
|
|
|
|
2026-10-05 11:13:51 +00:00
|
|
|
for (const [key, raw] of Object.entries(input)) {
|
|
|
|
|
const field = specification
|
|
|
|
|
.toPrimitive()
|
|
|
|
|
.fields.find((item) => item.fieldId === key.toLowerCase());
|
|
|
|
|
const value = typeof raw === "string" ? raw.trim() : raw;
|
2026-10-10 10:01:58 +00:00
|
|
|
|
2026-10-05 11:13:51 +00:00
|
|
|
if (!field || key.toLowerCase() in characteristics || !validCharacteristic(field, value)) {
|
|
|
|
|
return Result.fail(new ResourceCatalogError("INVALID_CHARACTERISTICS"));
|
|
|
|
|
}
|
|
|
|
|
if (value !== "") {
|
|
|
|
|
characteristics[key.toLowerCase()] = value;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (
|
|
|
|
|
specification
|
|
|
|
|
.toPrimitive()
|
|
|
|
|
.fields.some((field) => field.required && !(field.fieldId in characteristics))
|
|
|
|
|
) {
|
|
|
|
|
return Result.fail(new ResourceCatalogError("REQUIRED_CHARACTERISTICS"));
|
|
|
|
|
}
|
2026-10-10 10:01:58 +00:00
|
|
|
|
2026-10-05 11:13:51 +00:00
|
|
|
return Result.ok(characteristics);
|
|
|
|
|
}
|
2026-10-05 15:01:32 +00:00
|
|
|
|
|
|
|
|
function sameIntegration(
|
|
|
|
|
first: ResourceInput["integration"],
|
|
|
|
|
second: ResourceInput["integration"]
|
|
|
|
|
) {
|
|
|
|
|
if (!(first && second)) {
|
|
|
|
|
return first === second;
|
|
|
|
|
}
|
|
|
|
|
if (first.kind === "mailbox" && second.kind === "mailbox") {
|
|
|
|
|
return (
|
|
|
|
|
first.addressFieldId === second.addressFieldId &&
|
|
|
|
|
first.contextFieldId === second.contextFieldId &&
|
|
|
|
|
first.mailboxKind === second.mailboxKind &&
|
|
|
|
|
first.accountId === second.accountId
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-10-10 10:01:58 +00:00
|
|
|
|
2026-10-05 15:01:32 +00:00
|
|
|
return (
|
|
|
|
|
first.kind === "access" &&
|
|
|
|
|
second.kind === "access" &&
|
|
|
|
|
first.referenceFieldId === second.referenceFieldId &&
|
|
|
|
|
first.resourceTypeId === second.resourceTypeId &&
|
|
|
|
|
first.systemId === second.systemId &&
|
|
|
|
|
first.environmentId === second.environmentId
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateIntegration(
|
|
|
|
|
data: ResourceInput,
|
|
|
|
|
characteristics: ResourceInput["characteristics"],
|
|
|
|
|
specification: ResourceSpecification
|
|
|
|
|
) {
|
|
|
|
|
const integration = data.integration;
|
2026-10-10 10:01:58 +00:00
|
|
|
|
2026-10-05 15:01:32 +00:00
|
|
|
if (!validIntegrationFields(integration, specification)) {
|
|
|
|
|
return "INVALID_CHARACTERISTICS" as const;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (integration && data.notes.trim().length > 4000) {
|
|
|
|
|
return "INVALID_REQUEST" as const;
|
|
|
|
|
}
|
|
|
|
|
if (integration?.kind === "mailbox") {
|
|
|
|
|
const address = characteristics[integration.addressFieldId];
|
2026-10-10 10:01:58 +00:00
|
|
|
|
2026-10-05 15:01:32 +00:00
|
|
|
if (typeof address !== "string" || !z.email().max(254).safeParse(address).success) {
|
|
|
|
|
return "INVALID_EMAIL" as const;
|
|
|
|
|
}
|
|
|
|
|
characteristics[integration.addressFieldId] = address.toLowerCase();
|
|
|
|
|
const context = characteristics[integration.contextFieldId];
|
2026-10-10 10:01:58 +00:00
|
|
|
|
2026-10-05 15:01:32 +00:00
|
|
|
if (typeof context === "string" && context.length > 255) {
|
|
|
|
|
return "INVALID_CHARACTERISTICS" as const;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (integration?.kind === "access") {
|
|
|
|
|
const reference = characteristics[integration.referenceFieldId];
|
2026-10-10 10:01:58 +00:00
|
|
|
|
2026-10-05 15:01:32 +00:00
|
|
|
if (typeof reference === "string" && reference.length > 255) {
|
|
|
|
|
return "INVALID_CHARACTERISTICS" as const;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-10 10:01:58 +00:00
|
|
|
|
2026-10-05 15:01:32 +00:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validIntegrationFields(
|
|
|
|
|
integration: ResourceInput["integration"],
|
|
|
|
|
specification: ResourceSpecification
|
|
|
|
|
) {
|
|
|
|
|
const fields =
|
|
|
|
|
integration?.kind === "mailbox"
|
|
|
|
|
? [integration.addressFieldId, integration.contextFieldId]
|
|
|
|
|
: integration?.kind === "access"
|
|
|
|
|
? [integration.referenceFieldId]
|
|
|
|
|
: [];
|
2026-10-10 10:01:58 +00:00
|
|
|
|
2026-10-05 15:01:32 +00:00
|
|
|
return !(
|
|
|
|
|
new Set(fields).size !== fields.length ||
|
|
|
|
|
fields.some(
|
|
|
|
|
(id) =>
|
|
|
|
|
!specification
|
|
|
|
|
.toPrimitive()
|
|
|
|
|
.fields.some((field) => field.fieldId === id && field.type === "text")
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|