Incorporar recursos

This commit is contained in:
David Arranz 2026-10-05 13:13:51 +02:00
parent 6ab7e8f846
commit 03c1c30146
61 changed files with 4568 additions and 15 deletions

View File

@ -9,6 +9,7 @@ Aplicación interna para soporte técnico a pequeñas empresas.
- [Compañías, contactos y sedes](docs/companias-contactos-y-sedes.md)
- [Personas y departamentos](docs/personas-y-departamentos.md)
- [Cuentas, grupos y recursos](docs/cuentas-grupos-y-recursos.md)
- [Recursos, campos y plantillas](docs/recursos-campos-y-plantillas.md)
- [Inventario de activos](docs/inventario-de-activos.md)
- [Puestos de trabajo](docs/puestos-de-trabajo.md)
- [Perfiles de trabajo](docs/perfiles-de-trabajo.md)

View File

@ -21,7 +21,8 @@
"express-session": "1.19.0",
"connect-session-sequelize": "8.0.6",
"@metis/companies": "workspace:*",
"@metis/assets": "workspace:*"
"@metis/assets": "workspace:*",
"@metis/resources": "workspace:*"
},
"devDependencies": {
"@repo/typescript-config": "workspace:*",

View File

@ -15,6 +15,7 @@ import {
UserResponseSchema,
UsersResponseSchema,
} from "@metis/identity/common";
import { createResourceModule } from "@metis/resources/module";
import { QueryTypes } from "sequelize";
import { verifyAccess } from "./access.integration";
@ -25,6 +26,7 @@ import { createIdentityRuntime } from "./infrastructure/http/identity-runtime";
import { verifyInventory } from "./inventory.integration";
import { verifyMail } from "./mail.integration";
import { verifyRelationships } from "./relationships.integration";
import { verifyResources } from "./resources.integration";
import { verifyWorkProfiles } from "./work-profiles.integration";
import { verifyWorkstations } from "./workstations.integration";
@ -52,6 +54,11 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
await database.query("DELETE FROM workstation_assets");
await database.query("DELETE FROM workstation_revisions");
await database.query("DELETE FROM workstations");
await database.query("DELETE FROM resource_revisions");
await database.query("DELETE FROM resources");
await database.query("DELETE FROM resource_availability");
await database.query("DELETE FROM resource_template_fields");
await database.query("DELETE FROM resource_definitions");
await database.query("DELETE FROM asset_relationships");
await database.query("DELETE FROM asset_revisions");
await database.query("DELETE FROM assets");
@ -72,6 +79,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
await database.query("DELETE FROM company_sites");
await database.query("DELETE FROM companies");
await createAssetModule(database, (_request, _response, next) => next());
await createResourceModule(database, (_request, _response, next) => next());
// Simular la tabla de contactos de la versión anterior solo en la base de pruebas.
const legacyCompanyId = "48f13545-1529-4ba1-a353-a5f844141411";
const legacyPersonId = "4ec8f8c5-3ad9-4e22-abf3-b95370400898";
@ -150,6 +158,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
await competitor.session();
await expectError(await admin.request("/users"), 401, "UNAUTHENTICATED");
await expectError(await admin.request("/asset-catalog/global"), 401, "UNAUTHENTICATED");
await expectError(await admin.request("/resource-catalog/global"), 401, "UNAUTHENTICATED");
const first = {
name: "Admin test",
email: "ADMIN@example.test",
@ -705,6 +714,12 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
secondCompany.id,
database
);
await verifyResources(
(...args) => operator.request(...args),
customer.id,
secondCompany.id,
database
);
await verifyInventory(
(...args) => operator.request(...args),
customer.id,

View File

@ -1,6 +1,7 @@
import { createAssetModule } from "@metis/assets/module";
import { createCompanyModule } from "@metis/companies/module";
import { createIdentityModule, migrateIdentity } from "@metis/identity/module";
import { createResourceModule } from "@metis/resources/module";
import connectSequelize from "connect-session-sequelize";
import session from "express-session";
import type { Sequelize } from "sequelize";
@ -16,6 +17,7 @@ export async function createIdentityRuntime(
const identity = await createIdentityModule(database, environment.APP_ORIGIN);
const companies = await createCompanyModule(database, identity.authenticate);
const assets = await createAssetModule(database, identity.authenticate);
const resources = await createResourceModule(database, identity.authenticate);
const mail = await companies.initializeMail(assets.services.inventory);
const profiles = await companies.initializeProfiles(assets.services.inventory);
const SequelizeStore = connectSequelize(session.Store);
@ -42,6 +44,7 @@ export async function createIdentityRuntime(
identity.register(app);
companies.register(app);
assets.register(app);
resources.register(app);
mail.register(app);
profiles.register(app);
},

View File

@ -0,0 +1,257 @@
import assert from "node:assert/strict";
import { CatalogResponseSchema as AssetCatalogResponseSchema } from "@metis/assets/common";
import {
CatalogResponseSchema,
InventoryResponseSchema,
ResourceResponseSchema,
SaveDefinitionResponseSchema,
} from "@metis/resources/common";
import { createResourceModule } from "@metis/resources/module";
import type { Sequelize } from "sequelize";
import { QueryTypes } from "sequelize";
import { z } from "zod/v4";
export async function verifyResources(
request: (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>,
companyId: string,
otherId: string,
database: Sequelize
) {
const global = "/resource-catalog/global";
const company = `/resource-catalog/${companyId}`;
const other = `/resource-catalog/${otherId}`;
const inventory = `/resources/${companyId}`;
async function read(path: string) {
const response = await request(path);
assert.equal(response.status, 200);
return CatalogResponseSchema.parse(await response.json());
}
async function save(path: string, values: unknown) {
const response = await request(path, { values });
assert.ok([200, 201].includes(response.status), await response.clone().text());
return SaveDefinitionResponseSchema.parse(await response.json());
}
async function error(path: string, body: unknown, code: string, status = 409) {
const response = await request(path, body);
assert.equal(response.status, status);
assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code);
}
async function resource(path: string, values: unknown) {
const response = await request(path, { values });
assert.ok([200, 201].includes(response.status), await response.clone().text());
return ResourceResponseSchema.parse(await response.json()).resource;
}
const initial = await read(global);
assert.equal(initial.items.length, 16);
assert.equal(initial.items.filter((item) => item.values.kind === "template").length, 4);
assert.ok(initial.items.every((item) => item.builtin && item.id[14] === "7"));
assert.ok(
initial.items.every((item) =>
item.bindings.every((binding) => !binding.required && binding.id[14] === "7")
)
);
const assetsResponse = await request("/asset-catalog/global");
const assets = AssetCatalogResponseSchema.parse(await assetsResponse.json());
assert.ok(initial.items.every((item) => !assets.items.some((asset) => asset.id === item.id)));
const stock = initial.items.find((item) => item.values.name === "Dirección de correo");
const stockTemplate = initial.items.find((item) => item.values.name === "Buzón de correo");
assert.ok(stock && stockTemplate && stockTemplate.values.kind === "template");
await error(
`${global}/${stock.id}/update`,
{ values: { ...stock.values, name: "Cambio" } },
"BUILTIN_PROTECTED"
);
assert.equal(
(await request(`${global}/${stock.id}/status`, { active: false }, "https://other.example"))
.status,
403
);
assert.equal(
(await request(`${global}/${stock.id}/status`, { active: false }, undefined, "a".repeat(64)))
.status,
403
);
assert.equal((await request(`${company}/${stock.id}/status`, { active: false })).status, 200);
assert.equal(
(await read(other)).items.find((item) => item.id === stock.id)?.effectiveActive,
true
);
await createResourceModule(database, (_request, _response, next) => next());
const restarted = await read(global);
assert.deepEqual(
restarted.items.map((item) => item.id).sort(),
initial.items.map((item) => item.id).sort()
);
assert.equal(
(await read(company)).items.find((item) => item.id === stock.id)?.effectiveActive,
false
);
assert.equal((await request(`${company}/${stock.id}/status`, { active: null })).status, 200);
const definition = {
kind: "field",
name: "Marca",
type: "text",
unit: "",
description: "",
choices: [],
};
const local = await save(company, definition);
const fieldId = local.savedId;
assert.ok(!(await read(other)).items.some((item) => item.id === fieldId));
const assetField = assets.items.find((item) => item.values.kind === "field");
const assetTemplate = assets.items.find((item) => item.values.kind === "template");
assert.ok(assetField && assetTemplate);
const template = {
kind: "template",
name: "Recurso propio",
description: "",
fields: [{ fieldId, required: true }],
};
await error(
company,
{ values: { ...template, fields: [{ fieldId: assetField.id, required: false }] } },
"FIELD_UNAVAILABLE"
);
await error(
`/asset-catalog/${companyId}`,
{ values: { ...template, fields: [{ fieldId: stock.id, required: false }] } },
"FIELD_UNAVAILABLE"
);
await error(other, { values: template }, "FIELD_UNAVAILABLE");
await error(global, { values: template }, "FIELD_UNAVAILABLE");
const createdTemplate = await save(company, template);
await error(
`${other}/${createdTemplate.savedId}/update`,
{ values: template },
"DEFINITION_NOT_FOUND",
404
);
const first = {
name: "Zoom corporativo",
templateId: createdTemplate.savedId,
active: true,
notes: "",
characteristics: { [fieldId]: "Zoom" },
};
await error(
inventory,
{ values: { ...first, templateId: assetTemplate.id } },
"TEMPLATE_UNAVAILABLE"
);
await error(`/resources/${otherId}`, { values: first }, "TEMPLATE_UNAVAILABLE");
await error(inventory, { values: { ...first, characteristics: {} } }, "REQUIRED_CHARACTERISTICS");
await error(
inventory,
{ values: { ...first, characteristics: { [assetField.id]: "Ajeno" } } },
"INVALID_CHARACTERISTICS"
);
await error(inventory, { values: { ...first, personId: companyId } }, "INVALID_REQUEST", 400);
assert.equal((await request(inventory, { values: first }, "https://other.example")).status, 403);
assert.equal(
(await request(inventory, { values: first }, undefined, "a".repeat(64))).status,
403
);
const created = await resource(inventory, first);
assert.equal(created.id[14], "7");
assert.equal(created.history[0]?.id[14], "7");
const repeated = await resource(`${inventory}/${created.id}/update`, first);
assert.equal(repeated.history.length, 1);
assert.equal(repeated.id, created.id);
await error(
`/resources/${otherId}/${created.id}/update`,
{ values: first },
"RESOURCE_NOT_FOUND",
404
);
const altered = await resource(`${inventory}/${created.id}/update`, {
...first,
active: false,
notes: "Fin de uso",
});
assert.equal(altered.id, created.id);
assert.equal(altered.history.length, 2);
assert.equal(altered.history[0]?.values.active, true);
assert.equal(altered.values.active, false);
await error(inventory, { values: { ...first, name: " zoom CORPORATIVO " } }, "NAME_EXISTS");
await error(
`${inventory}/${created.id}/update`,
{ values: { ...first, templateId: stockTemplate.id } },
"TEMPLATE_FIXED"
);
// Cambiar una plantilla no altera las características ni la ficha ya registrada.
await save(`${company}/${createdTemplate.savedId}/update`, {
...template,
name: "Plantilla revisada",
fields: [{ fieldId: stock.id, required: false }],
});
const listResponse = await request(inventory);
const stored = InventoryResponseSchema.parse(await listResponse.json()).resources.find(
(item) => item.id === created.id
);
assert.ok(stored);
assert.equal(stored.specification.templateName, "Recurso propio");
assert.equal(stored.specification.fields[0]?.fieldId, fieldId);
await resource(`${inventory}/${created.id}/update`, {
...first,
active: false,
notes: "Notas posteriores",
});
// Una revisión fuera de secuencia se rechaza sin exponer detalles de persistencia.
const audit = await database.query<{ id: string; position: number }>(
"SELECT id, position FROM resource_revisions WHERE resourceId=:id ORDER BY position DESC LIMIT 1",
{ replacements: { id: created.id }, type: QueryTypes.SELECT }
);
const latest = audit[0];
assert.ok(latest);
try {
await database.query("UPDATE resource_revisions SET position=999 WHERE id=:id", {
replacements: { id: latest.id },
});
const inconsistent = await request(inventory);
assert.equal(inconsistent.status, 500);
const failure = z
.strictObject({ code: z.string(), requestId: z.string() })
.parse(await inconsistent.json());
assert.equal(failure.code, "INTERNAL_ERROR");
} finally {
await database.query("UPDATE resource_revisions SET position=:position WHERE id=:id", {
replacements: { id: latest.id, position: latest.position },
});
}
assert.equal((await request(inventory)).status, 200);
// Una plantilla desactivada impide altas, pero permite conservar y editar registros existentes.
assert.equal(
(await request(`${company}/${createdTemplate.savedId}/status`, { active: false })).status,
200
);
await error(inventory, { values: { ...first, name: "Otro" } }, "TEMPLATE_UNAVAILABLE");
await resource(`${inventory}/${created.id}/update`, {
...first,
active: false,
notes: "Plantilla desactivada",
});
assert.equal(
(await request(`${company}/${createdTemplate.savedId}/status`, { active: true })).status,
200
);
const concurrentInput = {
name: "Concurrente",
templateId: stockTemplate.id,
active: true,
notes: "",
characteristics: {},
};
const concurrent = await Promise.all([
request(inventory, { values: concurrentInput }),
request(inventory, { values: concurrentInput }),
]);
assert.deepEqual(concurrent.map((response) => response.status).sort(), [201, 409]);
// Los buzones anteriores y los recursos básicos continúan en sus superficies originales.
assert.equal((await request(`/mailboxes/${companyId}`)).status, 200);
assert.equal((await request(`/access/${companyId}`)).status, 200);
assert.equal((await request(`/resources/${otherId}`)).status, 200);
assert.equal((await request(`${inventory}?unexpected=1`)).status, 400);
assert.equal((await request("/resources/not-a-uuid")).status, 400);
assert.equal((await request("/resources/82be74bb-894f-4cca-ac7b-dedc96c15689")).status, 404);
}

View File

@ -21,7 +21,8 @@
"@repo/shadcn-ui": "workspace:*",
"@metis/identity": "workspace:*",
"@metis/companies": "workspace:*",
"@metis/assets": "workspace:*"
"@metis/assets": "workspace:*",
"@metis/resources": "workspace:*"
},
"devDependencies": {
"@repo/typescript-config": "workspace:*",
@ -36,4 +37,4 @@
"#lib/*": "./src/lib/*.ts",
"#hooks/*": "./src/hooks/*.ts"
}
}
}

View File

@ -7,6 +7,7 @@ import {
} from "@metis/companies/client";
import { coreEnglish, coreSpanish } from "@metis/core/client";
import { identityEnglish, identitySpanish } from "@metis/identity/client";
import { resourcesEnglish, resourcesSpanish } from "@metis/resources/client";
import { createInstance } from "i18next";
export function createAppI18n(language = "es") {
@ -23,6 +24,7 @@ export function createAppI18n(language = "es") {
companies: companiesSpanish,
access: accessSpanish,
assets: assetsSpanish,
resources: resourcesSpanish,
},
en: {
core: coreEnglish,
@ -30,6 +32,7 @@ export function createAppI18n(language = "es") {
companies: companiesEnglish,
access: accessEnglish,
assets: assetsEnglish,
resources: resourcesEnglish,
},
},
interpolation: { escapeValue: false },

View File

@ -47,6 +47,8 @@ AccessWorkspace es una raíz adicional para el catálogo global o la configuraci
AssetCatalog es la raíz del catálogo de activos; AssetDefinition, TemplateField y CatalogAvailability son entidades con UUID propio. AssetDefinitionValues reconstruye nombres y referencias como Value Objects. Consultar [Campos y plantillas de activos](../campos-y-plantillas-de-activos.md).
ResourceCatalog es la raíz del catálogo de recursos; ResourceDefinition, TemplateField y CatalogAvailability tienen UUID propios e independientes de activos. ResourceDefinitionValues encapsula las definiciones. Resource es la raíz del recurso registrado y ResourceRevision conserva sus revisiones; ResourceSpecification y ResourceValues capturan la plantilla y los valores inmutables. Consultar [Recursos, campos y plantillas](../recursos-campos-y-plantillas.md).
Asset es la raíz de cada activo real y AssetRevision una entidad con UUID propio, instantánea de valores, actor y fecha. AssetValues y AssetSpecification son Value Objects inmutables. La especificación capturada conserva los campos y reglas de la ficha aunque cambie el catálogo. Consultar [Inventario de activos](../inventario-de-activos.md).
AssetRelationship es la raíz de un vínculo entre dos activos de una compañía, con UUID propio y auditoría de creación y retirada. AssetRelationshipValues reconstruye extremos, tipo y nombres como Value Objects y conserva una instantánea. Consultar [Relaciones entre activos](../relaciones-entre-activos.md).

View File

@ -47,11 +47,16 @@ Persona y dispositivo fijos, cuenta de inicio de sesión opcional, perfiles vige
Buzones independientes corporativos o externos, cuenta informática opcional, periodos de uso por persona, configuraciones POP/IMAP por perfil y avisos de revisión. Los registros anteriores mantienen UUID e historial y se vinculan explícitamente. Consultar [Buzones y correo por perfil](correos-en-dispositivos.md).
## Recursos con campos y plantillas: base implementada
Catálogo independiente de activos, 12 campos y cuatro plantillas de serie, adiciones globales y por compañía, disponibilidad local y recursos con especificación capturada e historial. Los buzones y recursos básicos anteriores permanecen sin migrar. Consultar [Recursos, campos y plantillas](recursos-campos-y-plantillas.md).
## Siguientes entregas: orden actualizado
1. Configuraciones del perfil: carpetas compartidas, impresoras y recursos; después programas y certificados.
2. Teletrabajo y accesos remotos: autorización, equipo de origen, conectividad VPN y destino. Asistencia técnica remota independiente.
3. Procesos de alta, cambio y salida con seguimiento de configuraciones y retiradas. Plantillas reutilizables entre compañías; ejecuciones y datos propios.
1. Revisar y migrar los buzones y recursos básicos al nuevo modelo; comprobar UUID, historial, accesos y configuraciones antes de retirar la gestión anterior.
2. Configuraciones del perfil: carpetas compartidas, impresoras y recursos; después programas y certificados.
3. Teletrabajo y accesos remotos: autorización, equipo de origen, conectividad VPN y destino. Asistencia técnica remota independiente.
4. Procesos de alta, cambio y salida con seguimiento de configuraciones y retiradas. Plantillas reutilizables entre compañías; ejecuciones y datos propios.
Este orden se concreta antes de cada entrega. Las licencias y su relación con cuentas se explorarán posteriormente.

View File

@ -0,0 +1,62 @@
# Recursos, campos y plantillas
## Uso
El catálogo global de recursos se abre desde Catálogos globales → Catálogo de recursos. Cada compañía dispone de Recursos, para registrar elementos concretos, y Catálogo de recursos, para añadir definiciones propias y ajustar la disponibilidad.
Campos y plantillas de recursos son independientes de los de activos. Aunque se llamen igual, tienen UUID, datos, disponibilidad y referencias diferentes. El servidor rechaza campos de activos en plantillas de recursos y plantillas de activos al registrar recursos, y viceversa. El módulo resources no depende del módulo assets.
Las altas y ediciones se abren en modal desde la cabecera o una acción explícita del listado. La ayuda está antes del pie con Guardar y Cancelar. Los selectores de campo y plantilla ofrecen alta rápida mediante SelectWithAction. Se permite un modal adicional; ese formulario no abre un tercer nivel. Una definición guardada permanece aunque se cancele el formulario principal.
## Definiciones y ámbitos
Un campo representa una característica reutilizable de recursos. Admite texto, número, fecha, sí/no o lista de valores; nombre obligatorio, descripción y unidad opcionales. El tipo queda fijo tras el alta. En las listas se pueden añadir valores, pero no retirar los existentes.
Una plantilla reúne campos de recursos. Los campos comienzan como opcionales; en plantillas propias pueden marcarse obligatorios. Se puede retirar un campo de una plantilla sin borrar su definición.
Hay definiciones globales y adiciones por compañía. Una plantilla global solo admite campos globales. Una plantilla local admite campos globales y de su propia compañía. Los nombres del mismo tipo no se duplican en el catálogo visible, ignorando mayúsculas y espacios exteriores.
Cada compañía puede activar o desactivar una definición global para su ámbito. Usar configuración global elimina el efecto del ajuste local. Las definiciones de serie no se pueden editar; pueden desactivarse o copiarse como plantillas personalizables. No hay borrado físico.
## Datos de serie
Se incluyen 12 campos y cuatro plantillas:
| Plantilla | Campos |
| --- | --- |
| Buzón de correo | Dirección de correo, proveedor y contexto |
| Carpeta compartida | Ruta UNC, servidor como referencia documental y descripción |
| Aplicación | Proveedor, URL y descripción |
| Certificado digital | Titular, emisor, caducidad, huella digital y finalidad |
Todas las características de serie son opcionales. Los datos se alimentan con claves de serie estables: reiniciar no duplica definiciones, cambia UUID ni reactiva elementos desactivados. No se crean recursos, personas o compañías de ejemplo.
Esta primera base valida tipos de datos, fechas y valores de listas. Dirección de correo, URL y ruta UNC son características de texto; todavía no tienen validadores específicos de su significado ni vínculos estructurados con servidores. No se almacenan contraseñas ni claves privadas como funcionalidades de este catálogo.
## Recursos registrados
Un recurso pertenece a una compañía y tiene nombre, plantilla, estado activo, notas y características. No se pide persona, puesto ni dispositivo. Registrar un recurso no concede permisos ni configura equipos.
La plantilla queda fija tras el alta. Su especificación se captura al crear el recurso: nombres de campos, tipos, unidades, listas y obligatoriedad. Cambiar después el catálogo no reescribe las fichas ya registradas. Una plantilla desactivada impide nuevas altas, pero permite consultar y editar los recursos existentes con su especificación original.
El nombre es único dentro de compañía y plantilla, incluyendo recursos inactivos. Las modificaciones conservan UUID y añaden revisión con fecha y usuario de Metis Desk. Repetir una edición equivalente no añade historial.
## Arquitectura y persistencia
ResourceCatalog es AggregateRoot. ResourceDefinition, TemplateField y CatalogAvailability son DomainEntity con UUID propio. ResourceDefinitionValues encapsula definiciones inmutables y referencias como UniqueID.
Resource es AggregateRoot y ResourceRevision es DomainEntity. ResourceSpecification y ResourceValues son Value Objects con comparación semántica. Nuevas raíces, definiciones, asociaciones, ajustes y revisiones reciben UUID v7.
Las tablas resource_catalogs, resource_definitions, resource_template_fields, resource_availability, resources y resource_revisions son independientes de las tablas de activos. Las migraciones son explícitas e idempotentes, con claves foráneas. Las escrituras se realizan en transacción bloqueando el catálogo global y después la compañía. El mapper reconstruye valores y verifica referencias, ámbito, especificación y última revisión.
GET /api/resource-catalog/:scope consulta definiciones; POST en esa ruta crea; POST /api/resource-catalog/:scope/:id/update edita; POST /api/resource-catalog/:scope/:id/status ajusta disponibilidad. scope es global o UUID de compañía.
GET /api/resources/:companyId lista recursos; POST en esa ruta crea; POST /api/resources/:companyId/:id/update edita. Todas las rutas requieren sesión. Las escrituras exigen origen y token CSRF; el actor procede de la sesión.
## Convivencia y siguiente paso
Los buzones existentes, sus usos y configuraciones por perfil y los recursos básicos de Cuentas y accesos permanecen disponibles. Esta entrega no los copia, migra ni elimina. Sus UUID e historial se conservan.
Antes de migrarlos, se revisarán correspondencias, identidad, necesidades, permisos, cobertura y configuraciones por perfil. POP/IMAP, letras de unidad y versiones instaladas son datos de configuración, no características del recurso común.
Después de completar y comprobar esa migración se retirará la gestión anterior, evitando mantener dos fuentes para un mismo recurso. Los validadores específicos y las relaciones del nuevo recurso se concretarán junto con ese paso.

View File

@ -21,7 +21,8 @@
"react-hook-form": "7.72.1",
"@hookform/resolvers": "5.2.2",
"@tanstack/react-table": "8.21.3",
"@metis/assets": "workspace:*"
"@metis/assets": "workspace:*",
"@metis/resources": "workspace:*"
},
"devDependencies": {
"@repo/typescript-config": "workspace:*",

View File

@ -1,4 +1,5 @@
import { AssetCatalogPage } from "@metis/assets/client";
import { ResourceCatalogPage } from "@metis/resources/client";
import { Button } from "@repo/shadcn-ui/components/button";
import { Building2Icon, LayersIcon } from "@repo/shadcn-ui/lib/icons";
import { useQuery } from "@tanstack/react-query";
@ -27,11 +28,12 @@ export function CompaniesWorkspace({
}) {
const { t } = useTranslation("companies");
const { t: coreTranslation } = useTranslation("core");
const { t: resourceTranslation } = useTranslation("resources");
const { t: assetTranslation } = useTranslation("assets");
const { t: accessTranslation } = useTranslation("access");
const query = useQuery({ queryKey: companyKeys.list, queryFn: companyApi.list });
const [location, setLocation] = useState<WorkspaceLocation>();
const [catalog, setCatalog] = useState<"access" | "assets">("access");
const [catalog, setCatalog] = useState<"access" | "assets" | "resources">("access");
const companies = query.data?.companies ?? [];
const current =
location ?? (companies[0] ? { area: "company", id: companies[0].id } : { area: "directory" });
@ -135,6 +137,13 @@ export function CompaniesWorkspace({
>
{assetTranslation("title")}
</Button>
<Button
aria-pressed={catalog === "resources"}
onClick={() => setCatalog("resources")}
variant="ghost"
>
{resourceTranslation("title")}
</Button>
</>
) : (
<Button aria-pressed variant="ghost">
@ -146,10 +155,8 @@ export function CompaniesWorkspace({
<main className="company-space-main">
{current.area === "directory" ? (
<CompaniesPage csrfToken={csrfToken} onSelectCompany={selectCompany} />
) : catalog === "access" ? (
<AccessWorkspacePage csrfToken={csrfToken} scope="global" />
) : (
<AssetCatalogPage csrfToken={csrfToken} scope="global" />
<GlobalCatalogContent catalog={catalog} csrfToken={csrfToken} />
)}
</main>
</div>
@ -158,3 +165,20 @@ export function CompaniesWorkspace({
</div>
);
}
function GlobalCatalogContent({
catalog,
csrfToken,
}: {
catalog: "access" | "assets" | "resources";
csrfToken: string;
}) {
switch (catalog) {
case "access":
return <AccessWorkspacePage csrfToken={csrfToken} scope="global" />;
case "assets":
return <AssetCatalogPage csrfToken={csrfToken} scope="global" />;
case "resources":
return <ResourceCatalogPage csrfToken={csrfToken} scope="global" />;
}
}

View File

@ -1,5 +1,5 @@
import {FolderMappingsPage} from "../folders/folder-mappings-page";
import { AssetCatalogPage } from "@metis/assets/client";
import { ResourceCatalogPage, ResourcesPage } from "@metis/resources/client";
import { Button } from "@repo/shadcn-ui/components/button";
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
@ -7,6 +7,7 @@ import {
BoxIcon,
BriefcaseIcon,
KeyRoundIcon,
LayersIcon,
LayoutTemplateIcon,
MailIcon,
MapPinIcon,
@ -20,6 +21,7 @@ import { useTranslation } from "react-i18next";
import type { CompanyDTO } from "../../common";
import { AccessWorkspacePage } from "../access/access-workspace";
import { companyApi, companyKeys } from "../api/company-api";
import { FolderMappingsPage } from "../folders/folder-mappings-page";
import { MailConfigurationsPage } from "../mail/mail-configurations-page";
import { MailboxesPage } from "../mail/mailboxes-page";
import { WorkProfilesPage } from "../profiles/work-profiles-page";
@ -109,6 +111,8 @@ type CompanyTab =
| "departments"
| "sites"
| "access"
| "resource-catalog"
| "resources"
| "assets"
| "inventory"
| "workstations"
@ -131,6 +135,7 @@ export function CompanyDetail({
accountNavigation?: ReactNode;
}) {
const { t } = useTranslation("companies");
const { t: resourceTranslation } = useTranslation("resources");
const { t: assetTranslation } = useTranslation("assets");
const { t: accessTranslation } = useTranslation("access");
const query = useQuery({ queryKey: companyKeys.detail(id), queryFn: () => companyApi.get(id) });
@ -203,6 +208,7 @@ export function CompanyDetail({
{(
[
["inventory", assetTranslation("inventory"), BoxIcon],
["resources", resourceTranslation("inventory"), LayersIcon],
["workstations", assetTranslation("workstations"), BriefcaseIcon],
["profiles", t("profiles.title"), UsersIcon],
["people", `${t("people")} (${company.people.length})`, UsersIcon],
@ -213,10 +219,11 @@ export function CompanyDetail({
],
["sites", `${t("sites")} (${company.sites.length})`, MapPinIcon],
["access", accessTranslation("title"), KeyRoundIcon],
["folders",t("folders.title"),MailIcon],
["folders", t("folders.title"), MailIcon],
["mailboxes", t("mailbox.title"), MailIcon],
["mail", t("mail.title"), MailIcon],
["assets", assetTranslation("companyTitle"), LayoutTemplateIcon],
["resource-catalog", resourceTranslation("companyTitle"), LayoutTemplateIcon],
] as const
).map(([section, label, Icon]) => (
<Button
@ -264,8 +271,8 @@ function CompanySection({
case "profiles":
return <WorkProfilesPage company={company} csrfToken={csrfToken} />;
case "folders":
return <FolderMappingsPage company={company} csrfToken={csrfToken} />;
case "mailboxes":
return <FolderMappingsPage company={company} csrfToken={csrfToken} />;
case "mailboxes":
return <MailboxesPage company={company} csrfToken={csrfToken} />;
case "mail":
return <MailConfigurationsPage company={company} csrfToken={csrfToken} />;
@ -273,6 +280,10 @@ function CompanySection({
return <CompanyInventory company={company} csrfToken={csrfToken} workstations />;
case "inventory":
return <CompanyInventory company={company} csrfToken={csrfToken} />;
case "resources":
return <ResourcesPage companyId={company.id} csrfToken={csrfToken} />;
case "resource-catalog":
return <ResourceCatalogPage csrfToken={csrfToken} scope={company.id} />;
case "assets":
return <AssetCatalogPage csrfToken={csrfToken} scope={company.id} />;
}

View File

@ -0,0 +1,39 @@
{
"name": "@metis/resources",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
"./common": "./src/common/index.ts",
"./module": "./src/api/module.ts",
"./client": "./src/web/index.ts"
},
"scripts": {
"typecheck": "tsc -p tsconfig.json",
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/resource-catalog.test.ts src/api/domain/resource.test.ts src/web/catalog.test.tsx src/web/resources.test.tsx"
},
"dependencies": {
"@repo/rdx-ddd": "workspace:*",
"@repo/rdx-utils": "workspace:*",
"@repo/shadcn-ui": "workspace:*",
"sequelize": "6.37.8",
"zod": "4.3.6",
"react-hook-form": "7.72.1",
"@hookform/resolvers": "5.2.2"
},
"devDependencies": {
"@repo/typescript-config": "workspace:*",
"@types/node": "25.6.0",
"@types/express": "4.17.21",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3"
},
"peerDependencies": {
"express": "4.22.1",
"react": "19.2.5",
"react-dom": "19.2.5",
"i18next": "26.0.4",
"react-i18next": "17.0.2",
"@tanstack/react-query": "5.98.0"
}
}

View File

@ -0,0 +1,31 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Maybe } from "@repo/rdx-utils";
import type { CatalogResponseDTO } from "../../common";
import type { ResourceCatalog } from "../domain/resource-catalog.aggregate";
export function buildCatalogResponse(
catalog: ResourceCatalog,
scope: Maybe<UniqueID>
): CatalogResponseDTO {
return {
items: catalog.visible(scope).map((item) => ({
id: item.id.toPrimitive(),
companyId: item.companyId.getOrUndefined()?.toPrimitive() ?? null,
builtin: item.stockKey.getOrUndefined() !== undefined,
active: item.active,
effectiveActive: catalog.active(item, scope),
overridden: catalog.availability.some(
(entry) =>
entry.companyId.equals(scope.getOrUndefined()) &&
entry.definitionId.equals(item.id) &&
entry.active.getOrUndefined() !== undefined
),
values: item.values.data,
bindings: item.bindings.map((binding) => ({
id: binding.id.toPrimitive(),
fieldId: binding.fieldId.toPrimitive(),
required: binding.required,
})),
})),
};
}

View File

@ -0,0 +1,12 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Maybe, Result } from "@repo/rdx-utils";
import type { ResourceCatalog } from "../domain/resource-catalog.aggregate";
import type { ResourceCatalogError } from "../domain/resource-definition";
export interface IResourceCatalogRepository {
get(scope: Maybe<UniqueID>): Promise<Result<ResourceCatalog, ResourceCatalogError>>;
update(
scope: Maybe<UniqueID>,
action: (catalog: ResourceCatalog) => Result<ResourceCatalog, ResourceCatalogError>
): Promise<Result<ResourceCatalog, ResourceCatalogError>>;
}

View File

@ -0,0 +1,55 @@
import { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { ResourceDefinitionSpec } from "../../common";
import { ResourceCatalogError } from "../domain/resource-definition";
import { ResourceDefinitionValues } from "../domain/value-objects/resource-definition-values";
import type { IResourceCatalogRepository } from "./resource-catalog.repository";
export function catalogScope(value: string) {
if (value === "global") {
return Result.ok(Maybe.none<UniqueID>());
}
const id = UniqueID.create(value);
return id.isSuccess
? Result.ok(Maybe.some(id.data))
: Result.fail(new ResourceCatalogError("INVALID_REQUEST"));
}
export class ResourceCatalogUseCases {
constructor(private readonly repository: IResourceCatalogRepository) {}
async get(scope: string) {
const parsed = catalogScope(scope);
return parsed.isSuccess ? this.repository.get(parsed.data) : parsed;
}
async save(scope: string, identity: string | undefined, input: ResourceDefinitionSpec) {
const parent = catalogScope(scope);
const values = ResourceDefinitionValues.create(input);
const id = identity ? UniqueID.create(identity) : Result.ok(undefined);
if (!parent.isSuccess) {
return parent;
}
if (!values.isSuccess) {
return values;
}
if (!id.isSuccess) {
return Result.fail(new ResourceCatalogError("INVALID_REQUEST"));
}
return this.repository.update(parent.data, (catalog) =>
catalog.save(parent.data, id.data ? Maybe.some(id.data) : Maybe.none(), values.data)
);
}
async setActive(scope: string, identity: string, active: boolean | null) {
const parent = catalogScope(scope);
const id = UniqueID.create(identity);
if (!parent.isSuccess) {
return parent;
}
if (!id.isSuccess) {
return Result.fail(new ResourceCatalogError("INVALID_REQUEST"));
}
return this.repository.update(parent.data, (catalog) =>
catalog.setActive(parent.data, id.data, active)
);
}
}

View File

@ -0,0 +1,60 @@
import type { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { Resource } from "../domain/resource.aggregate";
import { ResourceCatalogError } from "../domain/resource-definition";
import type { ResourceInput } from "../domain/resource-values";
import { ResourceSpecification, ResourceValues } from "../domain/value-objects/resource-values";
import type { InventoryContext } from "./resource.repository";
export function mapResourceInput(
input: ResourceInput,
companyId: UniqueID,
context: InventoryContext,
previous?: Resource
) {
if (previous && previous.values.toPrimitive().templateId !== input.templateId.toLowerCase()) {
return Result.fail(new ResourceCatalogError("TEMPLATE_FIXED"));
}
let specification = previous?.specification;
if (!specification) {
const template = context.catalog
.visible(Maybe.some(companyId))
.find((item) => item.id.toPrimitive() === input.templateId.toLowerCase());
if (
!template ||
template.values.data.kind !== "template" ||
!context.catalog.active(template, Maybe.some(companyId))
) {
return Result.fail(new ResourceCatalogError("TEMPLATE_UNAVAILABLE"));
}
const fields = template.values.data.fields.map((binding) => {
const field = context.catalog.definitions.find(
(item) => item.id.toPrimitive() === binding.fieldId
);
if (!field || field.values.data.kind !== "field") {
throw new Error("Plantilla persistida inconsistente.");
}
return {
fieldId: field.id.toPrimitive(),
name: field.values.data.name,
type: field.values.data.type,
unit: field.values.data.unit,
choices: [...field.values.data.choices],
required: binding.required,
};
});
const created = ResourceSpecification.create({
templateId: template.id.toPrimitive(),
templateName: template.values.data.name,
fields,
});
if (!created.isSuccess) {
return created;
}
specification = created.data;
}
const values = ResourceValues.create(input, specification);
return values.isSuccess ? Result.ok({ specification, values: values.data }) : values;
}

View File

@ -0,0 +1,20 @@
import type { ResourceDTO } from "../../common/inventory";
import type { Resource } from "../domain/resource.aggregate";
export function buildResourceResponse(
resource: Resource,
actors: ReadonlyMap<string, string>
): ResourceDTO {
return {
id: resource.id.toPrimitive(),
companyId: resource.companyId.toPrimitive(),
specification: resource.specification.toPrimitive(),
values: resource.values.toPrimitive(),
history: resource.history.map((revision) => ({
id: revision.id.toPrimitive(),
values: revision.values.toPrimitive(),
actorId: revision.actorId.toPrimitive(),
actorName: actors.get(revision.actorId.toPrimitive()) ?? revision.actorId.toPrimitive(),
recordedAt: revision.recordedAt,
})),
};
}

View File

@ -0,0 +1,21 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Result } from "@repo/rdx-utils";
import type { Resource } from "../domain/resource.aggregate";
import type { ResourceCatalog } from "../domain/resource-catalog.aggregate";
import type { ResourceCatalogError } from "../domain/resource-definition";
export type InventoryContext = Readonly<{
catalog: ResourceCatalog;
resources: readonly Resource[];
actorNames: ReadonlyMap<string, string>;
}>;
export interface IResourceRepository {
get(companyId: UniqueID): Promise<Result<InventoryContext, ResourceCatalogError>>;
save(
companyId: UniqueID,
action: (context: InventoryContext) => Result<Resource, ResourceCatalogError>
): Promise<
Result<{ resource: Resource; actorNames: ReadonlyMap<string, string> }, ResourceCatalogError>
>;
}

View File

@ -0,0 +1,60 @@
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { Resource } from "../domain/resource.aggregate";
import { ResourceCatalogError } from "../domain/resource-definition";
import type { ResourceInput } from "../domain/resource-values";
import type { IResourceRepository } from "./resource.repository";
import { mapResourceInput } from "./resource-input.mapper";
export class ResourceUseCases {
constructor(private readonly repository: IResourceRepository) {}
get(companyId: string) {
const company = UniqueID.create(companyId);
if (!company.isSuccess) {
return Promise.resolve(Result.fail(new ResourceCatalogError("INVALID_REQUEST")));
}
return this.repository.get(company.data);
}
save(companyId: string, id: string | undefined, input: ResourceInput, actorId: string) {
const company = UniqueID.create(companyId);
const actor = UniqueID.create(actorId);
const identity = id ? UniqueID.create(id) : undefined;
if (!(company.isSuccess && actor.isSuccess) || (identity && !identity.isSuccess)) {
return Promise.resolve(Result.fail(new ResourceCatalogError("INVALID_REQUEST")));
}
return this.repository.save(company.data, (context) => {
const previous = identity?.isSuccess
? context.resources.find((resource) => resource.id.equals(identity.data))
: undefined;
if (id && !previous) {
return Result.fail(new ResourceCatalogError("RESOURCE_NOT_FOUND"));
}
const mapped = mapResourceInput(input, company.data, context, previous);
if (!mapped.isSuccess) {
return Result.fail(mapped.error);
}
if (
context.resources.some(
(item) =>
!item.id.equals(previous?.id) &&
item.values.toPrimitive().templateId === mapped.data.values.toPrimitive().templateId &&
item.values.name.toPrimitive().toLocaleLowerCase("es") ===
mapped.data.values.name.toPrimitive().toLocaleLowerCase("es")
)
) {
return Result.fail(new ResourceCatalogError("NAME_EXISTS"));
}
const now = new Date().toISOString();
return previous
? previous.update(mapped.data.values, actor.data, now)
: Resource.create(
company.data,
mapped.data.specification,
mapped.data.values,
actor.data,
now
);
});
}
}

View File

@ -0,0 +1,55 @@
import type { ResourceCatalog } from "./resource-catalog.aggregate";
import type { ResourceDefinitionSpec, ResourceFieldType } from "./resource-definition";
import { ResourceDefinitionValues } from "./value-objects/resource-definition-values";
const FIELDS: readonly [string, string, ResourceFieldType][] = [
["email", "Dirección de correo", "text"],
["provider", "Proveedor", "text"],
["context", "Contexto", "text"],
["unc", "Ruta UNC", "text"],
["server", "Servidor (referencia documental)", "text"],
["description", "Descripción", "text"],
["url", "URL", "text"],
["holder", "Titular", "text"],
["issuer", "Emisor", "text"],
["expires", "Caducidad", "date"],
["fingerprint", "Huella digital", "text"],
["purpose", "Finalidad", "text"],
];
const TEMPLATES: readonly [string, string, readonly string[]][] = [
["mailbox", "Buzón de correo", ["email", "provider", "context"]],
["shared_folder", "Carpeta compartida", ["unc", "server", "description"]],
["application", "Aplicación", ["provider", "url", "description"]],
["certificate", "Certificado digital", ["holder", "issuer", "expires", "fingerprint", "purpose"]],
];
/** Solo alimenta definiciones; no crea recursos ni migra registros anteriores. */
export function seedResourceCatalog(catalog: ResourceCatalog) {
let current = catalog;
const add = (key: string, input: ResourceDefinitionSpec) => {
const values = ResourceDefinitionValues.create(input);
if (!values.isSuccess) {
throw new Error("Definición inicial de recursos inválida.");
}
const result = current.seed(key, values.data);
if (!result.isSuccess) {
throw new Error("Catálogo inicial de recursos inconsistente.");
}
current = result.data;
};
for (const [key, name, type] of FIELDS) {
add(`field:${key}`, { kind: "field", name, description: "", type, unit: "", choices: [] });
}
for (const [key, name, fields] of TEMPLATES) {
const references = fields.map((field) => {
const id = current.definitions
.find((item) => item.stockKey.getOrUndefined() === `field:${field}`)
?.id.toPrimitive();
if (!id) {
throw new Error("Campo inicial de recursos ausente.");
}
return { fieldId: id, required: false };
});
add(`template:${key}`, { kind: "template", name, description: "", fields: references });
}
return current;
}

View File

@ -0,0 +1,184 @@
import { AggregateRoot, type UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { ResourceCatalogError } from "./resource-definition";
import { CatalogAvailability, ResourceDefinition } from "./resource-definition.entity";
import type { ResourceDefinitionValues } from "./value-objects/resource-definition-values";
type CatalogProps = {
definitions: readonly ResourceDefinition[];
availability: readonly CatalogAvailability[];
};
export class ResourceCatalog extends AggregateRoot<CatalogProps> {
private constructor(props: CatalogProps, id?: UniqueID) {
super(
{
definitions: Object.freeze([...props.definitions]),
availability: Object.freeze([...props.availability]),
},
id
);
}
static create() {
return new ResourceCatalog({ definitions: [], availability: [] });
}
static rehydrate(props: CatalogProps, id: UniqueID) {
return new ResourceCatalog(props, id);
}
get definitions() {
return this.props.definitions;
}
get availability() {
return this.props.availability;
}
visible(companyId: Maybe<UniqueID>) {
return this.definitions.filter(
(item) =>
!item.companyId.getOrUndefined() ||
item.companyId.getOrUndefined()?.equals(companyId.getOrUndefined())
);
}
active(item: ResourceDefinition, companyId: Maybe<UniqueID>) {
const override = this.availability.find(
(entry) =>
entry.companyId.equals(companyId.getOrUndefined()) && entry.definitionId.equals(item.id)
);
return override?.active.getOrUndefined() ?? item.active;
}
save(
companyId: Maybe<UniqueID>,
id: Maybe<UniqueID>,
values: ResourceDefinitionValues
): Result<ResourceCatalog, ResourceCatalogError> {
const previous = id.getOrUndefined()
? this.definitions.find((item) => item.id.equals(id.getOrUndefined()))
: undefined;
if (id.getOrUndefined() && !(previous && sameScope(previous.companyId, companyId))) {
return Result.fail(new ResourceCatalogError("DEFINITION_NOT_FOUND"));
}
const candidate = previous
? previous.update(values)
: Result.ok(ResourceDefinition.create(companyId, values, true));
if (!candidate.isSuccess) {
return candidate;
}
return this.replace(candidate.data, companyId, previous);
}
private replace(
candidate: ResourceDefinition,
scope: Maybe<UniqueID>,
previous?: ResourceDefinition
): Result<ResourceCatalog, ResourceCatalogError> {
const conflicts = scope.getOrUndefined() ? this.visible(scope) : this.definitions;
if (
conflicts.some(
(item) =>
!item.id.equals(candidate.id) &&
item.values.data.kind === candidate.values.data.kind &&
item.values.data.name.toLocaleLowerCase("es") ===
candidate.values.data.name.toLocaleLowerCase("es")
)
) {
return Result.fail(new ResourceCatalogError("NAME_EXISTS"));
}
const invalidField = this.invalidField(candidate, scope, previous);
if (invalidField) {
return Result.fail(new ResourceCatalogError("FIELD_UNAVAILABLE"));
}
if (candidate === previous) {
return Result.ok(this);
}
return Result.ok(
new ResourceCatalog(
{
...this.props,
definitions: previous
? this.definitions.map((item) => (item.id.equals(candidate.id) ? candidate : item))
: [...this.definitions, candidate],
},
this.id
)
);
}
private invalidField(
candidate: ResourceDefinition,
scope: Maybe<UniqueID>,
previous?: ResourceDefinition
) {
return candidate.values.fieldIds.some((id) => {
const field = this.visible(scope).find((item) => item.id.equals(id));
if (!field || field.values.data.kind !== "field") {
return true;
}
return !(
this.active(field, scope) ||
previous?.values.fieldIds.some((existing) => existing.equals(id))
);
});
}
seed(
stockKey: string,
values: ResourceDefinitionValues
): Result<ResourceCatalog, ResourceCatalogError> {
if (this.definitions.some((item) => item.stockKey.getOrUndefined() === stockKey)) {
return Result.ok(this);
}
return this.replace(
ResourceDefinition.create(Maybe.none(), values, true, Maybe.some(stockKey)),
Maybe.none()
);
}
setActive(
scope: Maybe<UniqueID>,
id: UniqueID,
active: boolean | null
): Result<ResourceCatalog, ResourceCatalogError> {
const item = this.visible(scope).find((entry) => entry.id.equals(id));
if (!item) {
return Result.fail(new ResourceCatalogError("DEFINITION_NOT_FOUND"));
}
const company = scope.getOrUndefined();
if (company && !item.companyId.getOrUndefined()) {
const previous = this.availability.find(
(entry) => entry.companyId.equals(company) && entry.definitionId.equals(id)
);
const value = active === null ? Maybe.none<boolean>() : Maybe.some(active);
const changed = previous
? previous.update(value)
: CatalogAvailability.create(company, id, value);
if (previous === changed || (!previous && active === null)) {
return Result.ok(this);
}
return Result.ok(
new ResourceCatalog(
{
...this.props,
availability: previous
? this.availability.map((entry) => (entry.id.equals(previous.id) ? changed : entry))
: [...this.availability, changed],
},
this.id
)
);
}
if (active === null) {
return Result.fail(new ResourceCatalogError("INVALID_REQUEST"));
}
const changed = item.setActive(active);
if (changed === item) {
return Result.ok(this);
}
return Result.ok(
new ResourceCatalog(
{
...this.props,
definitions: this.definitions.map((entry) => (entry.id.equals(id) ? changed : entry)),
},
this.id
)
);
}
}
function sameScope(a: Maybe<UniqueID>, b: Maybe<UniqueID>) {
return a.getOrUndefined()?.toPrimitive() === b.getOrUndefined()?.toPrimitive();
}

View File

@ -0,0 +1,112 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { UniqueID } from "@repo/rdx-ddd";
import { Maybe } from "@repo/rdx-utils";
import { ResourceCatalog } from "./resource-catalog.aggregate";
import { seedResourceCatalog } from "./resource-catalog-seed";
import type { ResourceDefinitionSpec } from "./resource-definition";
import { ResourceDefinitionValues } from "./value-objects/resource-definition-values";
function values(input: ResourceDefinitionSpec) {
const result = ResourceDefinitionValues.create(input);
assert.ok(result.isSuccess);
return result.data;
}
const field = {
kind: "field",
name: "Marca",
description: "",
type: "text",
unit: "",
choices: [],
} as const;
test("catálogo de recursos: serie idempotente, UUID v7, características opcionales y protección", () => {
const catalog = seedResourceCatalog(ResourceCatalog.create());
assert.equal(catalog.definitions.length, 16);
assert.equal(seedResourceCatalog(catalog), catalog);
assert.ok(catalog.definitions.every((item) => item.id.toPrimitive()[14] === "7"));
const template = catalog.definitions.find((item) => item.values.data.kind === "template");
assert.ok(template && template.values.data.kind === "template");
assert.ok(template.values.data.fields.every((binding) => !binding.required));
assert.ok(template.bindings.every((binding) => binding.id.toPrimitive()[14] === "7"));
const changed = catalog.save(
Maybe.none(),
Maybe.some(template.id),
values({ ...template.values.data, name: "Cambio" })
);
assert.ok(!changed.isSuccess);
assert.equal(changed.error.code, "BUILTIN_PROTECTED");
const disabled = catalog.setActive(Maybe.none(), template.id, false);
assert.ok(disabled.isSuccess);
assert.equal(
seedResourceCatalog(disabled.data).definitions.find((item) => item.id.equals(template.id))
?.active,
false
);
});
test("ámbitos, tipos, listas, campos externos y disponibilidad por compañía", () => {
const company = UniqueID.generateNewID();
const other = UniqueID.generateNewID();
const saved = ResourceCatalog.create().save(
Maybe.some(company),
Maybe.none(),
values({ ...field, choices: [] })
);
assert.ok(saved.isSuccess);
const local = saved.data.definitions[0];
assert.ok(local);
assert.equal(saved.data.visible(Maybe.some(other)).length, 0);
const template = (fieldId: string): ResourceDefinitionSpec => ({
kind: "template",
name: "Propia",
description: "",
fields: [{ fieldId, required: false }],
});
for (const [scope, id] of [
[Maybe.none<UniqueID>(), local.id],
[Maybe.some(other), local.id],
[Maybe.some(company), UniqueID.generateNewID()],
] as const) {
const result = saved.data.save(scope, Maybe.none(), values(template(id.toPrimitive())));
assert.ok(!result.isSuccess);
assert.equal(result.error.code, "FIELD_UNAVAILABLE");
}
const duplicate = saved.data.save(
Maybe.some(company),
Maybe.none(),
values({ ...field, choices: [], name: " marca " })
);
assert.ok(!duplicate.isSuccess);
assert.equal(duplicate.error.code, "NAME_EXISTS");
const changeType = local.update(values({ ...field, choices: [], type: "number" }));
assert.ok(!changeType.isSuccess);
assert.equal(changeType.error.code, "FIELD_TYPE_FIXED");
assert.equal(
ResourceDefinitionValues.create({ ...field, type: "choice", choices: ["A", " a "] }).isSuccess,
false
);
const choices = saved.data.save(
Maybe.none(),
Maybe.none(),
values({ ...field, name: "Lista", type: "choice", choices: ["A", "B"] })
);
assert.ok(choices.isSuccess);
const list = choices.data.definitions.find((item) => item.values.data.name === "Lista");
assert.ok(list);
assert.equal(
list.update(values({ ...field, name: "Lista", type: "choice", choices: ["A"] })).isSuccess,
false
);
const seeded = seedResourceCatalog(ResourceCatalog.create());
const stock = seeded.definitions[0];
assert.ok(stock);
const override = seeded.setActive(Maybe.some(company), stock.id, false);
assert.ok(override.isSuccess);
assert.equal(override.data.active(stock, Maybe.some(company)), false);
assert.equal(override.data.active(stock, Maybe.some(other)), true);
const reset = override.data.setActive(Maybe.some(company), stock.id, null);
assert.ok(reset.isSuccess);
assert.equal(reset.data.active(stock, Maybe.some(company)), true);
});

View File

@ -0,0 +1,133 @@
import { DomainEntity, type UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { ResourceCatalogError } from "./resource-definition";
import type { ResourceDefinitionValues } from "./value-objects/resource-definition-values";
export class TemplateField extends DomainEntity<{ fieldId: UniqueID; required: boolean }> {
static create(fieldId: UniqueID, required: boolean, id?: UniqueID) {
return new TemplateField({ fieldId, required }, id);
}
static rehydrate(fieldId: UniqueID, required: boolean, id: UniqueID) {
return new TemplateField({ fieldId, required }, id);
}
get fieldId() {
return this.props.fieldId;
}
get required() {
return this.props.required;
}
}
type DefinitionProps = {
companyId: Maybe<UniqueID>;
stockKey: Maybe<string>;
values: ResourceDefinitionValues;
active: boolean;
bindings: readonly TemplateField[];
};
export class ResourceDefinition extends DomainEntity<DefinitionProps> {
private constructor(props: DefinitionProps, id?: UniqueID) {
super({ ...props, bindings: Object.freeze([...props.bindings]) }, id);
}
static create(
companyId: Maybe<UniqueID>,
values: ResourceDefinitionValues,
active: boolean,
stockKey = Maybe.none<string>()
) {
const bindings = values.fieldIds.map((id, index) =>
TemplateField.create(
id,
values.data.kind === "template" && (values.data.fields[index]?.required ?? false)
)
);
return new ResourceDefinition({ companyId, stockKey, values, active, bindings });
}
static rehydrate(props: DefinitionProps, id: UniqueID) {
return new ResourceDefinition(props, id);
}
get companyId() {
return this.props.companyId;
}
get stockKey() {
return this.props.stockKey;
}
get values() {
return this.props.values;
}
get active() {
return this.props.active;
}
get bindings() {
return this.props.bindings;
}
update(values: ResourceDefinitionValues): Result<ResourceDefinition, ResourceCatalogError> {
if (this.stockKey.getOrUndefined()) {
return Result.fail(new ResourceCatalogError("BUILTIN_PROTECTED"));
}
const previous = this.values.data;
const next = values.data;
if (
previous.kind !== next.kind ||
(previous.kind === "field" &&
next.kind === "field" &&
(previous.type !== next.type ||
previous.choices.some((choice) => !next.choices.includes(choice))))
) {
return Result.fail(new ResourceCatalogError("FIELD_TYPE_FIXED"));
}
if (this.values.equals(values)) {
return Result.ok(this);
}
const bindings = values.fieldIds.map((fieldId, index) =>
TemplateField.create(
fieldId,
next.kind === "template" && (next.fields[index]?.required ?? false),
this.bindings.find((field) => field.fieldId.equals(fieldId))?.id
)
);
return Result.ok(new ResourceDefinition({ ...this.props, values, bindings }, this.id));
}
setActive(active: boolean) {
return active === this.active
? this
: new ResourceDefinition({ ...this.props, active }, this.id);
}
}
export class CatalogAvailability extends DomainEntity<{
companyId: UniqueID;
definitionId: UniqueID;
active: Maybe<boolean>;
}> {
static create(
companyId: UniqueID,
definitionId: UniqueID,
active: Maybe<boolean>,
id?: UniqueID
) {
return new CatalogAvailability({ companyId, definitionId, active }, id);
}
static rehydrate(
companyId: UniqueID,
definitionId: UniqueID,
active: Maybe<boolean>,
id: UniqueID
) {
return new CatalogAvailability({ companyId, definitionId, active }, id);
}
get companyId() {
return this.props.companyId;
}
get definitionId() {
return this.props.definitionId;
}
get active() {
return this.props.active;
}
update(active: Maybe<boolean>) {
return active.getOrUndefined() === this.active.getOrUndefined()
? this
: CatalogAvailability.create(this.companyId, this.definitionId, active, this.id);
}
}

View File

@ -0,0 +1,28 @@
import { z } from "zod/v4";
const title = z.string().min(1).max(100);
export const FieldTypeSchema = z.enum(["text", "number", "date", "boolean", "choice"]);
export const TemplateFieldSchema = z.strictObject({ fieldId: z.uuid(), required: z.boolean() });
export const ResourceDefinitionSchema = z.discriminatedUnion("kind", [
z.strictObject({
kind: z.literal("field"),
name: title,
description: z.string().max(1000),
type: FieldTypeSchema,
unit: z.string().max(30),
choices: z.array(title).max(100),
}),
z.strictObject({
kind: z.literal("template"),
name: title,
description: z.string().max(1000),
fields: z.array(TemplateFieldSchema).min(1).max(100),
}),
]);
export type ResourceDefinitionSpec = z.infer<typeof ResourceDefinitionSchema>;
export type ResourceFieldType = z.infer<typeof FieldTypeSchema>;
export class ResourceCatalogError extends Error {
constructor(public readonly code: string) {
super(code);
}
}

View File

@ -0,0 +1,25 @@
import { DomainEntity, type UniqueID } from "@repo/rdx-ddd";
import type { ResourceValues } from "./value-objects/resource-values";
export type ResourceRevisionProps = Readonly<{
values: ResourceValues;
actorId: UniqueID;
recordedAt: string;
}>;
export class ResourceRevision extends DomainEntity<ResourceRevisionProps> {
static create(props: ResourceRevisionProps) {
return new ResourceRevision(props);
}
static rehydrate(props: ResourceRevisionProps, id: UniqueID) {
return new ResourceRevision(props, id);
}
get values() {
return this.props.values;
}
get actorId() {
return this.props.actorId;
}
get recordedAt() {
return this.props.recordedAt;
}
}

View File

@ -0,0 +1,32 @@
import { z } from "zod/v4";
import { FieldTypeSchema } from "./resource-definition";
export const ResourceCharacteristicSchema = z.strictObject({
fieldId: z.uuid(),
name: z.string().min(1).max(100),
type: FieldTypeSchema,
unit: z.string().max(30),
choices: z.array(z.string().min(1).max(100)),
required: z.boolean(),
});
export const ResourceValueSchema = z.union([
z.string().max(10000),
z.number().finite(),
z.boolean(),
]);
export const ResourceInputSchema = z.strictObject({
name: z.string().min(1).max(100),
templateId: z.uuid(),
active: z.boolean(),
notes: z.string().max(10000),
characteristics: z.record(z.uuid(), ResourceValueSchema),
});
export const ResourceSpecificationSchema = z.strictObject({
templateId: z.uuid(),
templateName: z.string().min(1).max(100),
fields: z.array(ResourceCharacteristicSchema).min(1).max(100),
});
export type ResourceInput = z.infer<typeof ResourceInputSchema>;
export type ResourceSpecificationData = z.infer<typeof ResourceSpecificationSchema>;
export type ResourceCharacteristic = z.infer<typeof ResourceCharacteristicSchema>;

View File

@ -0,0 +1,70 @@
import { AggregateRoot, type UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { ResourceCatalogError } from "./resource-definition";
import { ResourceRevision } from "./resource-revision.entity";
import type { ResourceSpecification, ResourceValues } from "./value-objects/resource-values";
type ResourceProps = Readonly<{
companyId: UniqueID;
specification: ResourceSpecification;
values: ResourceValues;
history: readonly ResourceRevision[];
}>;
export class Resource extends AggregateRoot<ResourceProps> {
private constructor(props: ResourceProps, id?: UniqueID) {
super({ ...props, history: Object.freeze([...props.history]) }, id);
}
static create(
companyId: UniqueID,
specification: ResourceSpecification,
values: ResourceValues,
actorId: UniqueID,
recordedAt: string
) {
return Result.ok<Resource>(
new Resource({
companyId,
specification,
values,
history: [ResourceRevision.create({ values, actorId, recordedAt })],
})
);
}
static rehydrate(props: ResourceProps, id: UniqueID) {
return new Resource(props, id);
}
get companyId() {
return this.props.companyId;
}
get specification() {
return this.props.specification;
}
get values() {
return this.props.values;
}
get history() {
return this.props.history;
}
update(
values: ResourceValues,
actorId: UniqueID,
recordedAt: string
): Result<Resource, ResourceCatalogError> {
if (values.toPrimitive().templateId !== this.values.toPrimitive().templateId) {
return Result.fail(new ResourceCatalogError("TEMPLATE_FIXED"));
}
return Result.ok(
this.values.equals(values)
? this
: new Resource(
{
...this.props,
values,
history: [...this.history, ResourceRevision.create({ values, actorId, recordedAt })],
},
this.id
)
);
}
}

View File

@ -0,0 +1,108 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { UniqueID } from "@repo/rdx-ddd";
import { Resource } from "./resource.aggregate";
import type { ResourceInput } from "./resource-values";
import { ResourceInputSchema } from "./resource-values";
import { ResourceSpecification, ResourceValues } from "./value-objects/resource-values";
const templateId = UniqueID.generateNewID().toPrimitive();
const ids = Array.from({ length: 5 }, () => UniqueID.generateNewID().toPrimitive());
function specification(required = false) {
const types = ["text", "number", "date", "boolean", "choice"] as const;
const result = ResourceSpecification.create({
templateId,
templateName: "Aplicación",
fields: ids.map((fieldId, index) => ({
fieldId,
name: types[index] ?? "text",
type: types[index] ?? "text",
unit: "",
choices: index === 4 ? ["A", "B"] : [],
required: required && index === 3,
})),
});
assert.ok(result.isSuccess);
return result.data;
}
function input(characteristics: ResourceInput["characteristics"] = {}): ResourceInput {
return { name: " Zoom corporativo ", templateId, active: true, notes: "", characteristics };
}
test("recursos: tipos, valores opcionales y obligatorios, cero/falso y campos desconocidos", () => {
const spec = specification();
assert.ok(ResourceValues.create(input(), spec).isSuccess);
const typed = Object.fromEntries(
ids.map((id, index) => [id, ["texto", 0, "2026-02-28", false, "A"][index] ?? ""])
);
const valid = ResourceValues.create(input(typed), spec);
assert.ok(valid.isSuccess);
assert.equal(valid.data.toPrimitive().name, "Zoom corporativo");
for (const [index, value] of [
[1, "12"],
[2, "2026-02-30"],
[3, "false"],
[4, "C"],
] as const) {
const id = ids[index];
assert.ok(id);
assert.equal(ResourceValues.create(input({ [id]: value }), spec).isSuccess, false);
}
assert.equal(
ResourceValues.create(input({ [UniqueID.generateNewID().toPrimitive()]: "extra" }), spec)
.isSuccess,
false
);
assert.equal(ResourceValues.create(input(), specification(true)).isSuccess, false);
const boolean = ids[3];
assert.ok(boolean);
assert.ok(ResourceValues.create(input({ [boolean]: false }), specification(true)).isSuccess);
assert.equal(
ResourceInputSchema.safeParse({ ...input(), personId: UniqueID.generateNewID().toPrimitive() })
.success,
false
);
});
test("recurso: identidad estable, historial, desactivación e idempotencia", () => {
const spec = specification();
const first = ResourceValues.create(input(), spec);
assert.ok(first.isSuccess);
const actor = UniqueID.generateNewID();
const created = Resource.create(
UniqueID.generateNewID(),
spec,
first.data,
actor,
"2026-10-05T12:00:00.000Z"
);
assert.ok(created.isSuccess);
const resource = created.data;
assert.equal(resource.id.toPrimitive()[14], "7");
assert.equal(resource.history[0]?.id.toPrimitive()[14], "7");
const same = resource.update(first.data, actor, "2026-10-05T13:00:00.000Z");
assert.ok(same.isSuccess);
assert.equal(same.data, resource);
const next = ResourceValues.create({ ...input(), active: false, notes: "Fin de uso" }, spec);
assert.ok(next.isSuccess);
const changed = resource.update(next.data, actor, "2026-10-05T13:00:00.000Z");
assert.ok(changed.isSuccess);
assert.ok(changed.data.id.equals(resource.id));
assert.equal(changed.data.history.length, 2);
assert.equal(changed.data.history[0]?.values.toPrimitive().active, true);
assert.equal(changed.data.values.toPrimitive().active, false);
assert.ok(Object.isFrozen(changed.data.values.toPrimitive().characteristics));
const otherSpec = ResourceSpecification.create({
...spec.toPrimitive(),
templateId: UniqueID.generateNewID().toPrimitive(),
});
assert.ok(otherSpec.isSuccess);
const otherValues = ResourceValues.create(
{ ...input(), templateId: otherSpec.data.toPrimitive().templateId },
otherSpec.data
);
assert.ok(otherValues.isSuccess);
const changeTemplate = resource.update(otherValues.data, actor, "2026-10-05T14:00:00.000Z");
assert.ok(!changeTemplate.isSuccess);
assert.equal(changeTemplate.error.code, "TEMPLATE_FIXED");
});

View File

@ -0,0 +1,97 @@
import { Name, UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { ResourceDefinitionSpec } from "../resource-definition";
import { ResourceCatalogError, ResourceDefinitionSchema } from "../resource-definition";
/** Definición inmutable con nombre y referencias reconstruidos como Value Objects. */
export class ResourceDefinitionValues {
private constructor(
public readonly data: Readonly<ResourceDefinitionSpec>,
public readonly name: Name,
public readonly fieldIds: readonly UniqueID[]
) {
Object.freeze(this);
}
static create(
input: ResourceDefinitionSpec
): Result<ResourceDefinitionValues, ResourceCatalogError> {
const parsed = ResourceDefinitionSchema.safeParse(input);
const name = Name.create(input.name, 100);
if (!(parsed.success && name.isSuccess)) {
return Result.fail(new ResourceCatalogError("INVALID_REQUEST"));
}
const data = parsed.data;
if (data.kind === "field") {
const choices = data.choices.map((value) => value.trim());
if (
(data.type === "choice" && !choices.length) ||
(data.type !== "choice" && choices.length) ||
choices.some((choice) => !choice) ||
new Set(choices.map((choice) => choice.toLocaleLowerCase("es"))).size !== choices.length
) {
return Result.fail(new ResourceCatalogError("INVALID_CHOICES"));
}
const values = {
...data,
name: name.data.toPrimitive(),
description: data.description.trim(),
unit: data.unit.trim(),
choices,
};
Object.freeze(values.choices);
Object.freeze(values);
return Result.ok(new ResourceDefinitionValues(values, name.data, Object.freeze([])));
}
const ids: UniqueID[] = [];
for (const field of data.fields) {
const id = UniqueID.create(field.fieldId);
if (!id.isSuccess || ids.some((previous) => previous.equals(id.data))) {
return Result.fail(new ResourceCatalogError("INVALID_FIELDS"));
}
ids.push(id.data);
}
const fields = data.fields.map((field, index) => ({
fieldId: ids[index]?.toPrimitive() ?? field.fieldId,
required: field.required,
}));
fields.forEach(Object.freeze);
Object.freeze(fields);
return Result.ok(
new ResourceDefinitionValues(
Object.freeze({
...data,
name: name.data.toPrimitive(),
description: data.description.trim(),
fields,
}),
name.data,
Object.freeze(ids)
)
);
}
equals(other: ResourceDefinitionValues): boolean {
const a = this.data;
const b = other.data;
if (a.kind !== b.kind || a.name !== b.name || a.description !== b.description) {
return false;
}
if (a.kind === "field" && b.kind === "field") {
return (
a.type === b.type &&
a.unit === b.unit &&
a.choices.length === b.choices.length &&
a.choices.every((choice, index) => choice === b.choices[index])
);
}
return (
a.kind === "template" &&
b.kind === "template" &&
a.fields.length === b.fields.length &&
a.fields.every(
(field, index) =>
field.fieldId === b.fields[index]?.fieldId && field.required === b.fields[index]?.required
)
);
}
}

View File

@ -0,0 +1,182 @@
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";
export class ResourceSpecification {
private constructor(
private readonly data: ResourceSpecificationData,
public readonly templateId: UniqueID,
public readonly name: Name
) {
Object.freeze(this);
}
toPrimitive() {
return this.data;
}
static create(
input: ResourceSpecificationData
): Result<ResourceSpecification, ResourceCatalogError> {
const parsed = ResourceSpecificationSchema.safeParse(input);
if (
!parsed.success ||
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);
if (!(template.isSuccess && name.isSuccess)) {
return Result.fail(new ResourceCatalogError("INVALID_REQUEST"));
}
const frozenFields = input.fields.map((field) => {
const choices = [...field.choices];
Object.freeze(choices);
return Object.freeze({ ...field, fieldId: field.fieldId.toLowerCase(), choices });
});
Object.freeze(frozenFields);
return Result.ok(
new ResourceSpecification(
Object.freeze({
templateId: template.data.toPrimitive(),
templateName: name.data.toPrimitive(),
fields: frozenFields,
}),
template.data,
name.data
)
);
}
equals(other: ResourceSpecification | undefined) {
if (!(other instanceof ResourceSpecification)) {
return false;
}
const first = this.toPrimitive();
const second = other.toPrimitive();
return (
first.templateId === second.templateId &&
first.templateName === second.templateName &&
first.fields.length === second.fields.length &&
first.fields.every((field, index) => {
const candidate = second.fields[index];
return (
candidate !== undefined &&
field.fieldId === candidate.fieldId &&
field.name === candidate.name &&
field.type === candidate.type &&
field.unit === candidate.unit &&
field.required === candidate.required &&
field.choices.length === candidate.choices.length &&
field.choices.every((choice, position) => choice === candidate.choices[position])
);
})
);
}
}
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;
}
}
export class ResourceValues {
private constructor(
private readonly data: ResourceInput,
public readonly name: Name
) {
Object.freeze(this);
}
toPrimitive() {
return this.data;
}
static create(
input: ResourceInput,
specification: ResourceSpecification
): Result<ResourceValues, ResourceCatalogError> {
const parsed = ResourceInputSchema.safeParse(input);
const name = Name.create(input.name, 100);
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);
if (!parsedCharacteristics.isSuccess) {
return parsedCharacteristics;
}
const characteristics = parsedCharacteristics.data;
return Result.ok(
new ResourceValues(
Object.freeze({
...data,
name: name.data.toPrimitive(),
templateId: data.templateId.toLowerCase(),
notes: data.notes.trim(),
characteristics: Object.freeze(characteristics),
}),
name.data
)
);
}
equals(other: ResourceValues | undefined) {
if (!(other instanceof ResourceValues)) {
return false;
}
const first = this.toPrimitive();
const second = other.toPrimitive();
return (
first.name === second.name &&
first.templateId === second.templateId &&
first.active === second.active &&
first.notes === second.notes &&
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"] = {};
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;
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"));
}
return Result.ok(characteristics);
}

View File

@ -0,0 +1,109 @@
import type { Result } from "@repo/rdx-utils";
import type { Application, RequestHandler, Response } from "express";
import { Router } from "express";
import { z } from "zod/v4";
import {
AvailabilityRequestSchema,
CatalogItemParamsSchema,
CatalogParamsSchema,
SaveDefinitionRequestSchema,
} from "../../../common";
import type { ResourceCatalogUseCases } from "../../application/resource-catalog.use-cases";
import { catalogScope } from "../../application/resource-catalog.use-cases";
import { buildCatalogResponse } from "../../application/resource-catalog-response.builder";
import type { ResourceCatalog } from "../../domain/resource-catalog.aggregate";
import type { ResourceCatalogError } from "../../domain/resource-definition";
function sendError(response: Response, code: string) {
const status = code === "INVALID_REQUEST" ? 400 : code.endsWith("NOT_FOUND") ? 404 : 409;
response.status(status).json({ code, requestId: response.getHeader("x-request-id") });
}
function sendResult(
response: Response,
result: Result<ResourceCatalog, ResourceCatalogError>,
scope: string,
status = 200,
savedName?: { id?: string | undefined; name: string; kind: string }
) {
if (!result.isSuccess) {
sendError(response, result.error.code);
return;
}
const parsed = catalogScope(scope);
if (!parsed.isSuccess) {
sendError(response, "INVALID_REQUEST");
return;
}
const body = buildCatalogResponse(result.data, parsed.data);
if (!savedName) {
response.status(status).json(body);
return;
}
const savedId =
savedName.id?.toLowerCase() ??
body.items.find(
(item) =>
item.companyId === (scope === "global" ? null : scope.toLowerCase()) &&
item.values.kind === savedName.kind &&
item.values.name === savedName.name.trim()
)?.id;
if (!savedId) {
throw new Error("Resultado del catálogo inconsistente.");
}
response.status(status).json({ ...body, savedId });
}
export function registerResourceCatalogRoutes(
app: Application,
useCases: ResourceCatalogUseCases,
authenticate: RequestHandler
) {
const router = Router();
router.get("/:scope", (request, response, next) => {
const params = CatalogParamsSchema.safeParse(request.params);
if (!(params.success && z.strictObject({}).safeParse(request.query).success)) {
sendError(response, "INVALID_REQUEST");
return;
}
useCases
.get(params.data.scope)
.then((result) => sendResult(response, result, params.data.scope))
.catch(next);
});
for (const path of ["/:scope", "/:scope/:id/update"]) {
router.post(path, (request, response, next) => {
const schema = path.endsWith("update") ? CatalogItemParamsSchema : CatalogParamsSchema;
const params = schema.safeParse(request.params);
const body = SaveDefinitionRequestSchema.safeParse(request.body);
if (!(params.success && body.success)) {
sendError(response, "INVALID_REQUEST");
return;
}
const identity =
"id" in params.data && typeof params.data.id === "string" ? params.data.id : undefined;
useCases
.save(params.data.scope, identity, body.data.values)
.then((result) =>
sendResult(response, result, params.data.scope, identity ? 200 : 201, {
id: identity,
name: body.data.values.name,
kind: body.data.values.kind,
})
)
.catch(next);
});
}
router.post("/:scope/:id/status", (request, response, next) => {
const params = CatalogItemParamsSchema.safeParse(request.params);
const body = AvailabilityRequestSchema.safeParse(request.body);
if (!(params.success && body.success)) {
sendError(response, "INVALID_REQUEST");
return;
}
useCases
.setActive(params.data.scope, params.data.id, body.data.active)
.then((result) => sendResult(response, result, params.data.scope))
.catch(next);
});
app.use("/api/resource-catalog", authenticate, router);
}

View File

@ -0,0 +1,75 @@
import type { Application, RequestHandler, Response } from "express";
import { Router } from "express";
import { z } from "zod/v4";
import {
InventoryParamsSchema,
ResourceParamsSchema,
SaveResourceRequestSchema,
} from "../../../common/inventory";
import type { ResourceUseCases } from "../../application/resource.use-cases";
import { buildResourceResponse } from "../../application/resource-response.builder";
function fail(response: Response, code: string) {
response
.status(code === "INVALID_REQUEST" ? 400 : code.endsWith("NOT_FOUND") ? 404 : 409)
.json({ code, requestId: response.getHeader("x-request-id") });
}
export function registerResourceRoutes(
app: Application,
useCases: ResourceUseCases,
authenticate: RequestHandler
) {
const router = Router();
router.get("/:companyId", (request, response, next) => {
const params = InventoryParamsSchema.safeParse(request.params);
if (!(params.success && z.strictObject({}).safeParse(request.query).success)) {
fail(response, "INVALID_REQUEST");
return;
}
useCases
.get(params.data.companyId)
.then((result) => {
if (!result.isSuccess) {
fail(response, result.error.code);
return;
}
response.json({
resources: result.data.resources.map((resource) =>
buildResourceResponse(resource, result.data.actorNames)
),
});
})
.catch(next);
});
for (const path of ["/:companyId", "/:companyId/:id/update"]) {
router.post(path, (request, response, next) => {
const params = (
path.endsWith("update") ? ResourceParamsSchema : InventoryParamsSchema
).safeParse(request.params);
const body = SaveResourceRequestSchema.safeParse(request.body);
const actor = z
.object({ userId: z.uuid() })
.safeParse("session" in request ? request.session : undefined);
if (!(params.success && body.success && actor.success)) {
fail(response, "INVALID_REQUEST");
return;
}
const id =
"id" in params.data && typeof params.data.id === "string" ? params.data.id : undefined;
useCases
.save(params.data.companyId, id, body.data.values, actor.data.userId)
.then((result) => {
if (!result.isSuccess) {
fail(response, result.error.code);
return;
}
response.status(id ? 200 : 201).json({
resource: buildResourceResponse(result.data.resource, result.data.actorNames),
});
})
.catch(next);
});
}
app.use("/api/resources", authenticate, router);
}

View File

@ -0,0 +1,133 @@
import { UniqueID } from "@repo/rdx-ddd";
import { Maybe } from "@repo/rdx-utils";
import { ResourceCatalog } from "../../domain/resource-catalog.aggregate";
import { ResourceDefinitionSchema } from "../../domain/resource-definition";
import {
CatalogAvailability,
ResourceDefinition,
TemplateField,
} from "../../domain/resource-definition.entity";
import { ResourceDefinitionValues } from "../../domain/value-objects/resource-definition-values";
import type {
ResourceAvailabilityModel,
ResourceBindingModel,
ResourceCatalogModel,
ResourceDefinitionModel,
} from "./resource-catalog.model";
function identity(value: string) {
const parsed = UniqueID.create(value);
if (!parsed.isSuccess) {
throw new Error("Identidad de catálogo persistida inconsistente.");
}
return parsed.data;
}
export class ResourceCatalogPersistenceMapper {
toDomain(
root: ResourceCatalogModel,
rows: readonly ResourceDefinitionModel[],
fields: readonly ResourceBindingModel[],
overrides: readonly ResourceAvailabilityModel[]
) {
const definitions = rows.map((row) => {
const spec = ResourceDefinitionSchema.safeParse(row.values);
if (!spec.success) {
throw new Error("Definición de recurso persistida inconsistente.");
}
const values = ResourceDefinitionValues.create(spec.data);
if (!values.isSuccess) {
throw new Error("Valores de catálogo persistidos inconsistentes.");
}
const bindings = fields
.filter((field) => field.templateId === row.id)
.sort((a, b) => a.position - b.position)
.map((field) =>
TemplateField.rehydrate(identity(field.fieldId), field.required, identity(field.id))
);
if (
bindings.length !== values.data.fieldIds.length ||
bindings.some(
(binding, index) =>
!binding.fieldId.equals(values.data.fieldIds[index]) ||
(values.data.data.kind === "template" &&
binding.required !== values.data.data.fields[index]?.required)
)
) {
throw new Error("Referencias de plantilla persistidas inconsistentes.");
}
return ResourceDefinition.rehydrate(
{
values: values.data,
active: row.active,
companyId: row.companyId ? Maybe.some(identity(row.companyId)) : Maybe.none(),
stockKey: row.stockKey ? Maybe.some(row.stockKey) : Maybe.none(),
bindings,
},
identity(row.id)
);
});
const availability = overrides.map((row) =>
CatalogAvailability.rehydrate(
identity(row.companyId),
identity(row.definitionId),
row.active === null ? Maybe.none() : Maybe.some(row.active),
identity(row.id)
)
);
const catalog = ResourceCatalog.rehydrate({ definitions, availability }, identity(root.id));
validateReferences(catalog);
return catalog;
}
toPersistence(catalog: ResourceCatalog) {
return {
definitions: catalog.definitions.map((item) => ({
id: item.id.toPrimitive(),
catalogId: catalog.id.toPrimitive(),
companyId: item.companyId.getOrUndefined()?.toPrimitive() ?? null,
stockKey: item.stockKey.getOrUndefined() ?? null,
values: item.values.data,
active: item.active,
})),
bindings: catalog.definitions.flatMap((item) =>
item.bindings.map((field, position) => ({
id: field.id.toPrimitive(),
templateId: item.id.toPrimitive(),
fieldId: field.fieldId.toPrimitive(),
required: field.required,
position,
}))
),
availability: catalog.availability.map((item) => ({
id: item.id.toPrimitive(),
catalogId: catalog.id.toPrimitive(),
companyId: item.companyId.toPrimitive(),
definitionId: item.definitionId.toPrimitive(),
active: item.active.getOrUndefined() ?? null,
})),
};
}
}
function validateReferences(catalog: ResourceCatalog) {
for (const item of catalog.definitions) {
if (item.stockKey.getOrUndefined() && item.companyId.getOrUndefined()) {
throw new Error("Ámbito de definición inicial persistido inconsistente.");
}
for (const fieldId of item.values.fieldIds) {
const field = catalog
.visible(item.companyId)
.find((candidate) => candidate.id.equals(fieldId));
if (!field || field.values.data.kind !== "field") {
throw new Error("Ámbito de campo persistido inconsistente.");
}
}
}
for (const override of catalog.availability) {
const definition = catalog.definitions.find((item) => item.id.equals(override.definitionId));
if (!definition || definition.companyId.getOrUndefined()) {
throw new Error("Disponibilidad de catálogo persistida inconsistente.");
}
}
}

View File

@ -0,0 +1,125 @@
import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize";
import { DataTypes, Model, QueryTypes } from "sequelize";
export class ResourceCatalogModel extends Model<
InferAttributes<ResourceCatalogModel>,
InferCreationAttributes<ResourceCatalogModel>
> {
declare id: string;
declare catalogKey: string;
}
export class ResourceDefinitionModel extends Model<
InferAttributes<ResourceDefinitionModel>,
InferCreationAttributes<ResourceDefinitionModel>
> {
declare id: string;
declare catalogId: string;
declare companyId: string | null;
declare stockKey: string | null;
declare values: unknown;
declare active: boolean;
}
export class ResourceBindingModel extends Model<
InferAttributes<ResourceBindingModel>,
InferCreationAttributes<ResourceBindingModel>
> {
declare id: string;
declare templateId: string;
declare fieldId: string;
declare required: boolean;
declare position: number;
}
export class ResourceAvailabilityModel extends Model<
InferAttributes<ResourceAvailabilityModel>,
InferCreationAttributes<ResourceAvailabilityModel>
> {
declare id: string;
declare catalogId: string;
declare companyId: string;
declare definitionId: string;
declare active: boolean | null;
}
const id = { type: DataTypes.UUID, primaryKey: true };
const fk = (model: string, nullable = false) => ({
type: DataTypes.UUID,
allowNull: nullable,
references: { model, key: "id" },
onDelete: "RESTRICT",
});
export async function migrateResourceCatalog(database: Sequelize) {
const query = database.getQueryInterface();
const tables = await database.query<{ name: string }>(
"SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()",
{ type: QueryTypes.SELECT }
);
const create = async (name: string, columns: Parameters<typeof query.createTable>[1]) => {
if (!tables.some((table) => table.name === name)) {
await query.createTable(name, columns);
}
};
const catalog = {
id,
catalogKey: { type: DataTypes.STRING(20), unique: true, allowNull: false },
};
const definition = {
id,
catalogId: fk("resource_catalogs"),
companyId: fk("companies", true),
stockKey: { type: DataTypes.STRING(100), allowNull: true, unique: true },
values: { type: DataTypes.JSON, allowNull: false },
active: { type: DataTypes.BOOLEAN, allowNull: false },
};
const binding = {
id,
templateId: fk("resource_definitions"),
fieldId: fk("resource_definitions"),
required: { type: DataTypes.BOOLEAN, allowNull: false },
position: { type: DataTypes.INTEGER, allowNull: false },
};
const availability = {
id,
catalogId: fk("resource_catalogs"),
companyId: fk("companies"),
definitionId: fk("resource_definitions"),
active: { type: DataTypes.BOOLEAN, allowNull: true },
};
await create("resource_catalogs", catalog);
await create("resource_definitions", definition);
await create("resource_template_fields", binding);
await create("resource_availability", availability);
for (const [table, fields, name] of [
["resource_template_fields", ["templateId", "fieldId"], "resource_template_field_unique"],
[
"resource_availability",
["companyId", "definitionId"],
"resource_company_availability_unique",
],
] as const) {
const indexes = await database.query<{ name: string }>(
"SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=:table",
{ replacements: { table }, type: QueryTypes.SELECT }
);
if (!indexes.some((index) => index.name === name)) {
await query.addIndex(table, [...fields], { unique: true, name });
}
}
ResourceCatalogModel.init(catalog, {
sequelize: database,
tableName: "resource_catalogs",
timestamps: false,
});
ResourceDefinitionModel.init(definition, {
sequelize: database,
tableName: "resource_definitions",
timestamps: false,
});
ResourceBindingModel.init(binding, {
sequelize: database,
tableName: "resource_template_fields",
timestamps: false,
});
ResourceAvailabilityModel.init(availability, {
sequelize: database,
tableName: "resource_availability",
timestamps: false,
});
}

View File

@ -0,0 +1,92 @@
import { UniqueID } from "@repo/rdx-ddd";
import { z } from "zod/v4";
import { Resource } from "../../domain/resource.aggregate";
import { ResourceRevision } from "../../domain/resource-revision.entity";
import { ResourceInputSchema, ResourceSpecificationSchema } from "../../domain/resource-values";
import { ResourceSpecification, ResourceValues } from "../../domain/value-objects/resource-values";
import type { ResourceModel, ResourceRevisionModel } from "./resource.model";
export function inventoryIdentity(value: string) {
const parsed = UniqueID.create(value);
if (!parsed.isSuccess) {
throw new Error("Identidad de recurso persistida inconsistente.");
}
return parsed.data;
}
export class ResourcePersistenceMapper {
toDomain(row: ResourceModel, revisions: readonly ResourceRevisionModel[]) {
const specification = ResourceSpecification.create(
ResourceSpecificationSchema.parse(row.specification)
);
if (!specification.isSuccess) {
throw new Error("Especificación de recurso persistida inconsistente.");
}
const values = ResourceValues.create(ResourceInputSchema.parse(row.values), specification.data);
if (!values.isSuccess) {
throw new Error("Valores de recurso persistidos inconsistentes.");
}
const current = values.data.toPrimitive();
if (row.templateId !== current.templateId) {
throw new Error("Referencia de recurso persistida inconsistente.");
}
const ordered = [...revisions].sort((a, b) => a.position - b.position);
const history = ordered.map((revision, position) => {
const previous = ResourceValues.create(
ResourceInputSchema.parse(revision.values),
specification.data
);
if (
!previous.isSuccess ||
revision.position !== position ||
(position > 0 && revision.recordedAt < (ordered[position - 1]?.recordedAt ?? "")) ||
!z.iso.datetime().safeParse(revision.recordedAt).success
) {
throw new Error("Historial de recurso persistido inconsistente.");
}
if (revision.resourceId !== row.id) {
throw new Error("Revisión fuera del recurso.");
}
return ResourceRevision.rehydrate(
{
values: previous.data,
actorId: inventoryIdentity(revision.actorId),
recordedAt: revision.recordedAt,
},
inventoryIdentity(revision.id)
);
});
if (!(history.length && history.at(-1)?.values.equals(values.data))) {
throw new Error("Última revisión de recurso inconsistente.");
}
return Resource.rehydrate(
{
companyId: inventoryIdentity(row.companyId),
specification: specification.data,
values: values.data,
history,
},
inventoryIdentity(row.id)
);
}
toPersistence(resource: Resource) {
const values = resource.values.toPrimitive();
return {
resource: {
id: resource.id.toPrimitive(),
companyId: resource.companyId.toPrimitive(),
templateId: values.templateId,
specification: resource.specification.toPrimitive(),
values,
},
revisions: resource.history.map((revision, position) => ({
id: revision.id.toPrimitive(),
resourceId: resource.id.toPrimitive(),
actorId: revision.actorId.toPrimitive(),
recordedAt: revision.recordedAt,
values: revision.values.toPrimitive(),
position,
})),
};
}
}

View File

@ -0,0 +1,74 @@
import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize";
import { DataTypes, Model, QueryTypes } from "sequelize";
export class ResourceModel extends Model<
InferAttributes<ResourceModel>,
InferCreationAttributes<ResourceModel>
> {
declare id: string;
declare companyId: string;
declare templateId: string;
declare specification: unknown;
declare values: unknown;
}
export class ResourceRevisionModel extends Model<
InferAttributes<ResourceRevisionModel>,
InferCreationAttributes<ResourceRevisionModel>
> {
declare id: string;
declare resourceId: string;
declare actorId: string;
declare recordedAt: string;
declare values: unknown;
declare position: number;
}
const id = { type: DataTypes.UUID, primaryKey: true };
const fk = (model: string) => ({
type: DataTypes.UUID,
allowNull: false,
references: { model, key: "id" },
onDelete: "RESTRICT",
});
export async function migrateInventory(database: Sequelize) {
const query = database.getQueryInterface();
const tables = await database.query<{ name: string }>(
"SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()",
{ type: QueryTypes.SELECT }
);
const resource = {
id,
companyId: fk("companies"),
templateId: fk("resource_definitions"),
specification: { type: DataTypes.JSON, allowNull: false },
values: { type: DataTypes.JSON, allowNull: false },
};
const revision = {
id,
resourceId: fk("resources"),
actorId: fk("users"),
recordedAt: { type: DataTypes.STRING(30), allowNull: false },
values: { type: DataTypes.JSON, allowNull: false },
position: { type: DataTypes.INTEGER, allowNull: false },
};
if (!tables.some((table) => table.name === "resources")) {
await query.createTable("resources", resource);
}
if (!tables.some((table) => table.name === "resource_revisions")) {
await query.createTable("resource_revisions", revision);
}
const indexes = await database.query<{ name: string }>(
"SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='resource_revisions'",
{ type: QueryTypes.SELECT }
);
if (!indexes.some((index) => index.name === "resource_revision_position_unique")) {
await query.addIndex("resource_revisions", ["resourceId", "position"], {
unique: true,
name: "resource_revision_position_unique",
});
}
ResourceModel.init(resource, { sequelize: database, tableName: "resources", timestamps: false });
ResourceRevisionModel.init(revision, {
sequelize: database,
tableName: "resource_revisions",
timestamps: false,
});
}

View File

@ -0,0 +1,95 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Maybe } from "@repo/rdx-utils";
import { Result } from "@repo/rdx-utils";
import type { Sequelize, Transaction } from "sequelize";
import { Op, QueryTypes } from "sequelize";
import type { IResourceCatalogRepository } from "../../application/resource-catalog.repository";
import { ResourceCatalogError } from "../../domain/resource-definition";
import {
ResourceAvailabilityModel,
ResourceBindingModel,
ResourceCatalogModel,
ResourceDefinitionModel,
} from "./resource-catalog.model";
import type { ResourceCatalogPersistenceMapper } from "./resource-catalog-persistence.mapper";
export class SequelizeResourceCatalogRepository implements IResourceCatalogRepository {
constructor(
private readonly database: Sequelize,
private readonly mapper: ResourceCatalogPersistenceMapper
) {}
private async load(scope: Maybe<UniqueID>, transaction: Transaction, write: boolean) {
// Un bloqueo global serializa cambios de definiciones, referencias y disponibilidad.
const root = await ResourceCatalogModel.findOne({
where: { catalogKey: "global" },
transaction,
...(write ? { lock: transaction.LOCK.UPDATE } : {}),
});
if (!root) {
throw new Error("Catálogo de recursos no inicializado.");
}
const company = scope.getOrUndefined();
if (company) {
const rows = await this.database.query<{ id: string }>(
"SELECT id FROM companies WHERE id=:id",
{ replacements: { id: company.toPrimitive() }, type: QueryTypes.SELECT, transaction }
);
if (!rows.length) {
return Result.fail(new ResourceCatalogError("COMPANY_NOT_FOUND"));
}
}
const definitions = await ResourceDefinitionModel.findAll({
where: { catalogId: root.id },
transaction,
});
const bindings = definitions.length
? await ResourceBindingModel.findAll({
where: { templateId: definitions.map((item) => item.id) },
transaction,
})
: [];
const overrides = await ResourceAvailabilityModel.findAll({
where: { catalogId: root.id },
transaction,
});
return Result.ok(this.mapper.toDomain(root, definitions, bindings, overrides));
}
get(scope: Maybe<UniqueID>) {
return this.database.transaction((transaction) => this.load(scope, transaction, false));
}
update(scope: Maybe<UniqueID>, action: Parameters<IResourceCatalogRepository["update"]>[1]) {
return this.database.transaction(async (transaction) => {
const loaded = await this.load(scope, transaction, true);
if (!loaded.isSuccess) {
return loaded;
}
const changed = action(loaded.data);
if (!changed.isSuccess || changed.data === loaded.data) {
return changed;
}
const rows = this.mapper.toPersistence(changed.data);
// Primero las definiciones, después sus relaciones: las FK no admiten huérfanos.
for (const row of rows.definitions) {
await ResourceDefinitionModel.upsert(row, { transaction });
}
const previousBindings = loaded.data.definitions.flatMap((item) => item.bindings);
const retained = new Set(rows.bindings.map((row) => row.id));
const removed = previousBindings.filter((field) => !retained.has(field.id.toPrimitive()));
if (removed.length) {
await ResourceBindingModel.destroy({
where: { id: { [Op.in]: removed.map((field) => field.id.toPrimitive()) } },
transaction,
});
}
for (const row of rows.bindings) {
await ResourceBindingModel.upsert(row, { transaction });
}
for (const row of rows.availability) {
await ResourceAvailabilityModel.upsert(row, { transaction });
}
return changed;
});
}
}

View File

@ -0,0 +1,136 @@
import type { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { Sequelize, Transaction } from "sequelize";
import { QueryTypes } from "sequelize";
import type { IResourceRepository, InventoryContext } from "../../application/resource.repository";
import { ResourceCatalogError } from "../../domain/resource-definition";
import { ResourceModel, ResourceRevisionModel } from "./resource.model";
import {
ResourceAvailabilityModel,
ResourceBindingModel,
ResourceCatalogModel,
ResourceDefinitionModel,
} from "./resource-catalog.model";
import { ResourceCatalogPersistenceMapper } from "./resource-catalog-persistence.mapper";
import { ResourcePersistenceMapper } from "./resource-persistence.mapper";
export class SequelizeResourceRepository implements IResourceRepository {
private readonly mapper = new ResourcePersistenceMapper();
constructor(private readonly database: Sequelize) {}
private async load(
companyId: UniqueID,
transaction: Transaction,
write: boolean
): Promise<Result<InventoryContext, ResourceCatalogError>> {
// Orden compartido con las escrituras del catálogo para evitar interbloqueos.
const root = await ResourceCatalogModel.findOne({
where: { catalogKey: "global" },
transaction,
...(write ? { lock: transaction.LOCK.UPDATE } : {}),
});
if (!root) {
throw new Error("Catálogo de recursos no inicializado.");
}
const companies = await this.database.query<{ id: string }>(
`SELECT id FROM companies WHERE id=:id${write ? " FOR UPDATE" : ""}`,
{ replacements: { id: companyId.toPrimitive() }, type: QueryTypes.SELECT, transaction }
);
if (!companies.length) {
return Result.fail(new ResourceCatalogError("COMPANY_NOT_FOUND"));
}
const definitions = await ResourceDefinitionModel.findAll({
where: { catalogId: root.id },
transaction,
});
const bindings = definitions.length
? await ResourceBindingModel.findAll({
where: { templateId: definitions.map((item) => item.id) },
transaction,
})
: [];
const overrides = await ResourceAvailabilityModel.findAll({
where: { catalogId: root.id },
transaction,
});
const catalog = new ResourceCatalogPersistenceMapper().toDomain(
root,
definitions,
bindings,
overrides
);
const rows = await ResourceModel.findAll({
where: { companyId: companyId.toPrimitive() },
transaction,
});
const revisions = rows.length
? await ResourceRevisionModel.findAll({
where: { resourceId: rows.map((item) => item.id) },
transaction,
})
: [];
const resources = rows.map((row) => {
const resource = this.mapper.toDomain(
row,
revisions.filter((revision) => revision.resourceId === row.id)
);
const spec = resource.specification.toPrimitive();
const template = catalog
.visible(Maybe.some(companyId))
.find((item) => item.id.toPrimitive() === spec.templateId);
if (
!template ||
template.values.data.kind !== "template" ||
spec.fields.some(
(field) =>
!catalog
.visible(Maybe.some(companyId))
.some(
(item) =>
item.id.toPrimitive() === field.fieldId && item.values.data.kind === "field"
)
)
) {
throw new Error("Especificación de recurso fuera de su catálogo o compañía.");
}
return resource;
});
const actors = await this.database.query<{ id: string; name: string }>(
"SELECT id, name FROM users",
{ type: QueryTypes.SELECT, transaction }
);
return Result.ok({
catalog,
resources,
actorNames: new Map(actors.map((actor) => [actor.id, actor.name])),
});
}
get(companyId: UniqueID) {
return this.database.transaction((transaction) => this.load(companyId, transaction, false));
}
save(companyId: UniqueID, action: Parameters<IResourceRepository["save"]>[1]) {
return this.database.transaction(async (transaction) => {
const loaded = await this.load(companyId, transaction, true);
if (!loaded.isSuccess) {
return loaded;
}
const changed = action(loaded.data);
if (!changed.isSuccess) {
return changed;
}
const persistence = this.mapper.toPersistence(changed.data);
await ResourceModel.upsert(persistence.resource, { transaction });
const existing = await ResourceRevisionModel.findAll({
where: { resourceId: persistence.resource.id },
transaction,
});
for (const revision of persistence.revisions) {
if (!existing.some((item) => item.id === revision.id)) {
await ResourceRevisionModel.create(revision, { transaction });
}
}
return Result.ok({ resource: changed.data, actorNames: loaded.data.actorNames });
});
}
}

View File

@ -0,0 +1,45 @@
import { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { RequestHandler } from "express";
import type { Sequelize } from "sequelize";
import { ResourceUseCases } from "./application/resource.use-cases";
import { ResourceCatalogUseCases } from "./application/resource-catalog.use-cases";
import { seedResourceCatalog } from "./domain/resource-catalog-seed";
import { registerResourceRoutes } from "./infrastructure/http/resource.routes";
import { registerResourceCatalogRoutes } from "./infrastructure/http/resource-catalog.routes";
import { migrateInventory } from "./infrastructure/persistence/resource.model";
import {
ResourceCatalogModel,
migrateResourceCatalog,
} from "./infrastructure/persistence/resource-catalog.model";
import { ResourceCatalogPersistenceMapper } from "./infrastructure/persistence/resource-catalog-persistence.mapper";
import { SequelizeResourceRepository } from "./infrastructure/persistence/sequelize-resource.repository";
import { SequelizeResourceCatalogRepository } from "./infrastructure/persistence/sequelize-resource-catalog.repository";
export async function createResourceModule(database: Sequelize, authenticate: RequestHandler) {
await migrateResourceCatalog(database);
await ResourceCatalogModel.findOrCreate({
where: { catalogKey: "global" },
defaults: { id: UniqueID.generateNewID().toPrimitive(), catalogKey: "global" },
});
const repository = new SequelizeResourceCatalogRepository(
database,
new ResourceCatalogPersistenceMapper()
);
const seeded = await repository.update(Maybe.none(), (catalog) =>
Result.ok(seedResourceCatalog(catalog))
);
if (!seeded.isSuccess) {
throw new Error("No se pudo inicializar el catálogo de recursos.");
}
await migrateInventory(database);
const catalog = new ResourceCatalogUseCases(repository);
const inventory = new ResourceUseCases(new SequelizeResourceRepository(database));
return {
register: (app: Parameters<typeof registerResourceCatalogRoutes>[0]) => {
registerResourceCatalogRoutes(app, catalog, authenticate);
registerResourceRoutes(app, inventory, authenticate);
},
};
}

View File

@ -0,0 +1,30 @@
import { z } from "zod/v4";
import { FieldTypeSchema, ResourceDefinitionSchema } from "../api/domain/resource-definition";
export type { ResourceDefinitionSpec, ResourceFieldType } from "../api/domain/resource-definition";
export { FieldTypeSchema, ResourceDefinitionSchema };
export const CatalogParamsSchema = z.strictObject({
scope: z.union([z.literal("global"), z.uuid()]),
});
export const CatalogItemParamsSchema = CatalogParamsSchema.extend({ id: z.uuid() });
export const SaveDefinitionRequestSchema = z.strictObject({ values: ResourceDefinitionSchema });
export const AvailabilityRequestSchema = z.strictObject({ active: z.boolean().nullable() });
export const CatalogItemSchema = z.strictObject({
id: z.uuid(),
companyId: z.uuid().nullable(),
builtin: z.boolean(),
active: z.boolean(),
effectiveActive: z.boolean(),
overridden: z.boolean(),
values: ResourceDefinitionSchema,
bindings: z.array(z.strictObject({ id: z.uuid(), fieldId: z.uuid(), required: z.boolean() })),
});
export const CatalogResponseSchema = z.strictObject({ items: z.array(CatalogItemSchema) });
export const SaveDefinitionResponseSchema = CatalogResponseSchema.extend({ savedId: z.uuid() });
export type CatalogItemDTO = z.infer<typeof CatalogItemSchema>;
export type CatalogResponseDTO = z.infer<typeof CatalogResponseSchema>;
export type SaveDefinitionResponseDTO = z.infer<typeof SaveDefinitionResponseSchema>;
export * from "./inventory";

View File

@ -0,0 +1,34 @@
import { z } from "zod/v4";
import {
ResourceInputSchema,
ResourceSpecificationSchema,
ResourceValueSchema,
} from "../api/domain/resource-values";
export type { ResourceCharacteristic, ResourceInput } from "../api/domain/resource-values";
export { ResourceInputSchema, ResourceSpecificationSchema, ResourceValueSchema };
export const InventoryParamsSchema = z.strictObject({ companyId: z.uuid() });
export const ResourceParamsSchema = InventoryParamsSchema.extend({ id: z.uuid() });
export const SaveResourceRequestSchema = z.strictObject({ values: ResourceInputSchema });
export const ResourceRevisionSchema = z.strictObject({
id: z.uuid(),
values: ResourceInputSchema,
actorId: z.uuid(),
actorName: z.string().min(1),
recordedAt: z.iso.datetime(),
});
export const ResourceSchema = z.strictObject({
id: z.uuid(),
companyId: z.uuid(),
specification: ResourceSpecificationSchema,
values: ResourceInputSchema,
history: z.array(ResourceRevisionSchema).min(1),
});
export const InventoryResponseSchema = z.strictObject({
resources: z.array(ResourceSchema),
});
export const ResourceResponseSchema = z.strictObject({ resource: ResourceSchema });
export type ResourceDTO = z.infer<typeof ResourceSchema>;
export type ResourceResponseDTO = z.infer<typeof ResourceResponseSchema>;

View File

@ -0,0 +1,42 @@
import { z } from "zod/v4";
import type { ResourceDefinitionSpec } from "../common";
import { CatalogResponseSchema, SaveDefinitionResponseSchema } from "../common";
export const catalogKeys = { scope: (scope: string) => ["resource-catalog", scope] };
export class CatalogApiError extends Error {
constructor(public readonly code: string) {
super(code);
}
}
async function request(path: string, body?: unknown, csrfToken?: string) {
const response = await fetch(`/api/resource-catalog/${path}`, {
method: body === undefined ? "GET" : "POST",
credentials: "same-origin",
headers:
body === undefined
? {}
: { "content-type": "application/json", "x-csrf-token": csrfToken ?? "" },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const json: unknown = await response.json();
if (!response.ok) {
const error = z.object({ code: z.string() }).safeParse(json);
throw new CatalogApiError(error.success ? error.data.code : "INTERNAL_ERROR");
}
return json;
}
export const catalogApi = {
async get(scope: string) {
return CatalogResponseSchema.parse(await request(scope));
},
async save(scope: string, values: ResourceDefinitionSpec, csrfToken: string, id?: string) {
return SaveDefinitionResponseSchema.parse(
await request(`${scope}${id ? `/${id}/update` : ""}`, { values }, csrfToken)
);
},
async status(scope: string, id: string, active: boolean | null, csrfToken: string) {
return CatalogResponseSchema.parse(
await request(`${scope}/${id}/status`, { active }, csrfToken)
);
},
};

View File

@ -0,0 +1,85 @@
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { useTranslation } from "react-i18next";
import type { CatalogItemDTO } from "../common";
import { CatalogHelp } from "./catalog-help";
export function CatalogDefinitionDialog({
item,
items,
onClose,
}: {
item?: CatalogItemDTO | undefined;
items: readonly CatalogItemDTO[];
onClose: () => void;
}) {
const { t } = useTranslation("resources");
if (!item) {
return null;
}
const values = item.values;
return (
<FormDialog
cancelLabel={t("close")}
onOpenChange={(open) => {
if (!open) {
onClose();
}
}}
open
title={t("viewDefinition", { name: values.name })}
wide={values.kind === "template"}
>
<div className="space-y-4">
{values.description && (
<p className="text-sm text-muted-foreground">{values.description}</p>
)}
{values.kind === "field" ? (
<dl className="space-y-2 text-sm">
<div>
<dt className="font-medium">{t("type")}</dt>
<dd>{t(values.type)}</dd>
</div>
{values.unit && (
<div>
<dt className="font-medium">{t("unit")}</dt>
<dd>{values.unit}</dd>
</div>
)}
{values.choices.length > 0 && (
<div>
<dt className="font-medium">{t("choices")}</dt>
<dd className="whitespace-pre-line">{values.choices.join("\n")}</dd>
</div>
)}
</dl>
) : (
<ul className="divide-y rounded-md border">
{values.fields.map((binding) => {
const field = items.find((entry) => entry.id === binding.fieldId);
return (
<li className="flex justify-between gap-4 p-3 text-sm" key={binding.fieldId}>
<div>
<p className="font-medium">{field?.values.name}</p>
{field?.values.kind === "field" && (
<p className="text-muted-foreground">
{t(field.values.type)}
{field.values.unit ? ` · ${field.values.unit}` : ""}
</p>
)}
{field && !field.effectiveActive && (
<p className="text-muted-foreground">{t("disabledField")}</p>
)}
</div>
<span>{t(binding.required ? "required" : "optional")}</span>
</li>
);
})}
</ul>
)}
{item.builtin && <p className="text-sm text-muted-foreground">{t("protected")}</p>}
<CatalogHelp kind={values.kind} scope={item.companyId ?? "global"} />
</div>
</FormDialog>
);
}

View File

@ -0,0 +1,166 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@repo/shadcn-ui/components/button";
import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
import { Input } from "@repo/shadcn-ui/components/input";
import { Label } from "@repo/shadcn-ui/components/label";
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
import { Textarea } from "@repo/shadcn-ui/components/textarea";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useEffect, useId, useRef } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod/v4";
import type { CatalogItemDTO, ResourceDefinitionSpec, SaveDefinitionResponseDTO } from "../common";
import { FieldTypeSchema, ResourceDefinitionSchema } from "../common";
import { CatalogApiError, catalogApi, catalogKeys } from "./catalog-api";
import { CatalogHelp } from "./catalog-help";
export type CatalogSaved = (id: string, data: SaveDefinitionResponseDTO) => void;
export function useCatalogSave(
scope: string,
csrfToken: string,
onSaved: CatalogSaved,
id?: string
) {
const client = useQueryClient();
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
return useMutation({
mutationFn: (values: ResourceDefinitionSpec) => catalogApi.save(scope, values, csrfToken, id),
onSuccess: async (response) => {
await client.invalidateQueries({ queryKey: ["resource-catalog"] });
client.setQueryData(catalogKeys.scope(scope), { items: response.items });
if (mounted.current) {
onSaved(response.savedId, response);
}
},
});
}
export function CatalogError({
error,
invalid = false,
}: {
error?: Error | null | undefined;
invalid?: boolean;
}) {
const { t } = useTranslation("resources");
if (!(error || invalid)) {
return null;
}
return (
<p className="text-sm text-destructive" role="alert">
{t(
`errors.${invalid ? "INVALID_REQUEST" : error instanceof CatalogApiError ? error.code : "INTERNAL_ERROR"}`
)}
</p>
);
}
const FieldFormSchema = ResourceDefinitionSchema.options[0].omit({ choices: true }).extend({
choicesText: z.string().max(10000),
});
type FieldFormValues = z.infer<typeof FieldFormSchema>;
export function CatalogFieldForm({
scope,
csrfToken,
item,
onSaved,
}: {
scope: string;
csrfToken: string;
item?: CatalogItemDTO | undefined;
onSaved: CatalogSaved;
}) {
const { t } = useTranslation("resources");
const id = useId();
const current = item?.values.kind === "field" ? item.values : undefined;
const form = useForm<FieldFormValues>({
resolver: zodResolver(FieldFormSchema),
defaultValues: {
kind: "field",
name: current?.name ?? "",
description: current?.description ?? "",
type: current?.type ?? "text",
unit: current?.unit ?? "",
choicesText: current?.choices.join("\n") ?? "",
},
});
const mutation = useCatalogSave(scope, csrfToken, onSaved, item?.id);
const type = form.watch("type");
return (
<form
className="space-y-4"
onSubmit={form.handleSubmit(({ choicesText, ...values }) =>
mutation.mutate({
...values,
choices:
values.type === "choice"
? choicesText
.split(/\r?\n/)
.map((value) => value.trim())
.filter(Boolean)
: [],
})
)}
>
<div className="space-y-2">
<Label htmlFor={`${id}-name`}>{t("name")}</Label>
<Input id={`${id}-name`} maxLength={100} required {...form.register("name")} />
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-type`}>{t("type")}</Label>
<SelectWithAction
disabled={!!item || mutation.isPending}
id={`${id}-type`}
label={t("type")}
onValueChange={(value) => {
const parsed = FieldTypeSchema.safeParse(value);
if (parsed.success) {
form.setValue("type", parsed.data);
}
}}
options={FieldTypeSchema.options.map((value) => ({ value, label: t(value) }))}
placeholder={t("chooseType")}
required
value={type}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-unit`}>{t("unit")}</Label>
<Input id={`${id}-unit`} maxLength={30} {...form.register("unit")} />
</div>
{type === "choice" && (
<div className="space-y-2">
<Label htmlFor={`${id}-choices`}>{t("choices")}</Label>
<Textarea id={`${id}-choices`} required rows={5} {...form.register("choicesText")} />
<p className="text-xs text-muted-foreground">{t("choicesHelp")}</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor={`${id}-description`}>{t("description")}</Label>
<Textarea
id={`${id}-description`}
maxLength={1000}
rows={3}
{...form.register("description")}
/>
</div>
<CatalogError
error={mutation.error}
invalid={Object.keys(form.formState.errors).length > 0}
/>
<CatalogHelp kind="field" scope={scope} />
<FormActions>
<Button disabled={mutation.isPending} type="submit">
{t(mutation.isPending ? "saving" : "save")}
</Button>
</FormActions>
</form>
);
}

View File

@ -0,0 +1,15 @@
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
import { useTranslation } from "react-i18next";
export function CatalogHelp({ kind, scope }: { kind: "field" | "template"; scope: string }) {
const { t } = useTranslation("resources");
return (
<ContextHelp
description={t(`${kind}Description`)}
examples={t(`${kind}Examples`)}
examplesLabel={t("examplesLabel")}
guidance={t(`${kind}Guidance`)}
scopeNote={t(scope === "global" ? "globalHelp" : "companyHelp")}
title={t("helpTitle")}
/>
);
}

View File

@ -0,0 +1,303 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { Input } from "@repo/shadcn-ui/components/input";
import { Label } from "@repo/shadcn-ui/components/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@repo/shadcn-ui/components/table";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useId, useState } from "react";
import { useTranslation } from "react-i18next";
import type { CatalogItemDTO } from "../common";
import { catalogApi, catalogKeys } from "./catalog-api";
import { CatalogDefinitionDialog } from "./catalog-definition-dialog";
import { CatalogError, CatalogFieldForm } from "./catalog-form";
import { CatalogTemplateForm } from "./catalog-template-form";
type Editor = {
kind: "field" | "template";
item?: CatalogItemDTO;
copy?: CatalogItemDTO;
};
export function ResourceCatalogPage({ scope, csrfToken }: { scope: string; csrfToken: string }) {
const { t } = useTranslation("resources");
const searchId = useId();
const client = useQueryClient();
const [tab, setTab] = useState<"field" | "template">("template");
const [search, setSearch] = useState("");
const [view, setView] = useState<CatalogItemDTO>();
const [editor, setEditor] = useState<Editor>();
const query = useQuery({
queryKey: catalogKeys.scope(scope),
queryFn: () => catalogApi.get(scope),
});
const mutation = useMutation({
mutationFn: ({ item, active }: { item: CatalogItemDTO; active: boolean | null }) =>
catalogApi.status(scope, item.id, active, csrfToken),
onSuccess: async (response) => {
await client.invalidateQueries({ queryKey: ["resource-catalog"] });
client.setQueryData(catalogKeys.scope(scope), response);
},
});
const items = query.data?.items ?? [];
const visible = items.filter(
(item) =>
item.values.kind === tab &&
item.values.name.toLocaleLowerCase().includes(search.toLocaleLowerCase())
);
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 className="text-2xl font-semibold">
{t(scope === "global" ? "title" : "companyTitle")}
</h2>
<p className="mt-1 text-sm text-muted-foreground">
{t(scope === "global" ? "introduction" : "companyIntroduction")}
</p>
</div>
<Button disabled={!query.data} onClick={() => setEditor({ kind: tab })}>
{t({ field: "newField", template: "newTemplate" }[tab])}
</Button>
</div>
<nav aria-label={t("title")} className="flex gap-2">
{(["template", "field"] as const).map((kind) => (
<Button
aria-pressed={tab === kind}
key={kind}
onClick={() => {
setTab(kind);
setSearch("");
}}
variant={tab === kind ? "secondary" : "outline"}
>
{t({ field: "fields", template: "templates" }[kind])}
</Button>
))}
</nav>
<p className="text-sm text-muted-foreground">{t("protected")}</p>
{query.isPending && <p role="status">{t("loading")}</p>}
<CatalogError error={query.error ?? mutation.error} />
{query.isError && (
<Button
onClick={() => {
query.refetch().catch(() => undefined);
}}
variant="outline"
>
{t("retry")}
</Button>
)}
<Card>
<CardContent className="space-y-4 pt-6">
<div className="space-y-2">
<Label htmlFor={searchId}>{t("search")}</Label>
<Input
id={searchId}
onChange={(event) => setSearch(event.target.value)}
value={search}
/>
</div>
{query.data &&
(visible.length ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("name")}</TableHead>
<TableHead>{t("origin")}</TableHead>
<TableHead>{t({ field: "type", template: "fields" }[tab])}</TableHead>
<TableHead>{t("active")}</TableHead>
<TableHead>{t("actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visible.map((item) => (
<CatalogRow
item={item}
items={items}
key={item.id}
onCopy={() => setEditor({ kind: "template", copy: item })}
onEdit={() => setEditor({ kind: tab, item })}
onStatus={(active) => mutation.mutate({ item, active })}
onView={() => setView(item)}
pending={mutation.isPending}
scope={scope}
/>
))}
</TableBody>
</Table>
) : (
<p className="text-muted-foreground">{t("empty")}</p>
))}
</CardContent>
</Card>
<CatalogDefinitionDialog item={view} items={items} onClose={() => setView(undefined)} />
{editor && (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
setEditor(undefined);
}
}}
open
title={t(editorTitle(editor))}
wide={editor.kind === "template"}
>
{editor.kind === "field" ? (
<CatalogFieldForm
csrfToken={csrfToken}
item={editor.item}
onSaved={() => setEditor(undefined)}
scope={scope}
/>
) : (
<CatalogTemplateForm
copy={editor.copy}
csrfToken={csrfToken}
item={editor.item}
items={items}
onSaved={() => setEditor(undefined)}
scope={scope}
/>
)}
</FormDialog>
)}
</div>
);
}
function CatalogRow({
item,
scope,
items,
pending,
onEdit,
onCopy,
onView,
onStatus,
}: {
item: CatalogItemDTO;
scope: string;
items: readonly CatalogItemDTO[];
pending: boolean;
onEdit: () => void;
onCopy: () => void;
onView: () => void;
onStatus: (active: boolean | null) => void;
}) {
const { t } = useTranslation("resources");
const editable = !item.builtin && item.companyId === (scope === "global" ? null : scope);
const values = item.values;
const inactiveFields =
values.kind === "template" &&
values.fields.some(
(field) => !items.find((entry) => entry.id === field.fieldId)?.effectiveActive
);
return (
<TableRow>
<TableCell>
<p className="font-medium">{values.name}</p>
{values.description && (
<p className="text-xs text-muted-foreground">{values.description}</p>
)}
{inactiveFields && (
<p className="text-xs text-muted-foreground">{t("disabledFieldsNotice")}</p>
)}
</TableCell>
<TableCell>
{t(item.builtin ? "builtin" : "custom")}
<p className="text-xs text-muted-foreground">{t(item.companyId ? "own" : "global")}</p>
</TableCell>
<TableCell>
{values.kind === "field"
? t(values.type)
: t("fieldCount", { count: values.fields.length })}
</TableCell>
<TableCell>
{t(item.effectiveActive ? "active" : "inactive")}
{scope !== "global" && !item.companyId && (
<p className="text-xs text-muted-foreground">
{t(item.overridden ? "overridden" : "inherited")}
</p>
)}
</TableCell>
<TableCell>
<CatalogRowActions
editable={editable}
item={item}
onCopy={onCopy}
onEdit={onEdit}
onStatus={onStatus}
onView={onView}
pending={pending}
/>
</TableCell>
</TableRow>
);
}
function editorTitle(editor: Editor) {
if (editor.kind === "field") {
return editor.item ? "editField" : "newField";
}
return editor.item ? "editTemplate" : "newTemplate";
}
function CatalogRowActions({
item,
editable,
pending,
onEdit,
onCopy,
onView,
onStatus,
}: {
item: CatalogItemDTO;
editable: boolean;
pending: boolean;
onEdit: () => void;
onCopy: () => void;
onView: () => void;
onStatus: (active: boolean | null) => void;
}) {
const { t } = useTranslation("resources");
return (
<div className="flex flex-wrap gap-2">
<Button onClick={onView} size="sm" variant="outline">
{t("view")}
</Button>
{editable && (
<Button onClick={onEdit} size="sm" variant="outline">
{t("edit")}
</Button>
)}
{item.values.kind === "template" && (
<Button onClick={onCopy} size="sm" variant="outline">
{t("copy")}
</Button>
)}
<Button
disabled={pending}
onClick={() => onStatus(!item.effectiveActive)}
size="sm"
variant="outline"
>
{t(item.effectiveActive ? "deactivate" : "activate")}
</Button>
{item.overridden && (
<Button disabled={pending} onClick={() => onStatus(null)} size="sm" variant="ghost">
{t("reset")}
</Button>
)}
</div>
);
}

View File

@ -0,0 +1,198 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@repo/shadcn-ui/components/button";
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
import { FormActions, FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { Input } from "@repo/shadcn-ui/components/input";
import { Label } from "@repo/shadcn-ui/components/label";
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
import { Textarea } from "@repo/shadcn-ui/components/textarea";
import { useId, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { z } from "zod/v4";
import type { CatalogItemDTO } from "../common";
import { ResourceDefinitionSchema } from "../common";
import type { CatalogSaved } from "./catalog-form";
import { CatalogError, CatalogFieldForm, useCatalogSave } from "./catalog-form";
import { CatalogHelp } from "./catalog-help";
const TemplateFormSchema = ResourceDefinitionSchema.options[1];
type TemplateFormValues = z.infer<typeof TemplateFormSchema>;
export function CatalogTemplateForm({
scope,
csrfToken,
item,
copy,
items,
onSaved,
allowQuickCreate = true,
}: {
scope: string;
csrfToken: string;
item?: CatalogItemDTO | undefined;
copy?: CatalogItemDTO | undefined;
items: readonly CatalogItemDTO[];
onSaved: CatalogSaved;
allowQuickCreate?: boolean;
}) {
const { t } = useTranslation("resources");
const id = useId();
const [creatingField, setCreatingField] = useState(false);
const source = item ?? copy;
const current = source?.values.kind === "template" ? source.values : undefined;
const form = useForm<TemplateFormValues>({
resolver: zodResolver(TemplateFormSchema),
defaultValues: {
kind: "template",
name: copy ? t("copyName", { name: copy.values.name }) : (current?.name ?? ""),
description: current?.description ?? "",
fields: current?.fields.map((field) => ({ ...field })) ?? [],
},
});
const mutation = useCatalogSave(scope, csrfToken, onSaved, item?.id);
const selected = form.watch("fields");
const fields = items.filter((entry) => entry.values.kind === "field");
const add = (fieldId: string) => {
if (fieldId && !selected.some((field) => field.fieldId === fieldId)) {
form.setValue("fields", [...selected, { fieldId, required: false }], {
shouldDirty: true,
shouldValidate: true,
});
}
};
return (
<>
<form className="space-y-4" onSubmit={form.handleSubmit((values) => mutation.mutate(values))}>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`${id}-name`}>{t("name")}</Label>
<Input id={`${id}-name`} maxLength={100} required {...form.register("name")} />
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-description`}>{t("description")}</Label>
<Textarea
id={`${id}-description`}
maxLength={1000}
rows={2}
{...form.register("description")}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-field`}>{t("selectField")}</Label>
<SelectWithAction
action={
allowQuickCreate
? { label: t("addField"), onClick: () => setCreatingField(true) }
: undefined
}
disabled={mutation.isPending}
id={`${id}-field`}
label={t("selectField")}
onValueChange={add}
options={fields
.filter(
(entry) =>
entry.effectiveActive && !selected.some((field) => field.fieldId === entry.id)
)
.map((entry) => ({ value: entry.id, label: entry.values.name }))}
placeholder={t("choose")}
value=""
/>
</div>
{!selected.length && <p className="text-sm text-muted-foreground">{t("noFields")}</p>}
<div className="divide-y rounded-md border">
{selected.map((binding) => {
const field = fields.find((entry) => entry.id === binding.fieldId);
return (
<div className="flex items-center justify-between gap-3 p-3" key={binding.fieldId}>
<div>
<p className="text-sm font-medium">{field?.values.name ?? binding.fieldId}</p>
{field && !field.effectiveActive && (
<p className="text-xs text-muted-foreground">{t("disabledField")}</p>
)}
</div>
<div className="flex items-center gap-2">
<Checkbox
checked={binding.required}
disabled={mutation.isPending}
id={`${id}-required-${binding.fieldId}`}
onCheckedChange={(checked) =>
form.setValue(
"fields",
selected.map((entry) =>
entry.fieldId === binding.fieldId
? { ...entry, required: checked === true }
: entry
),
{ shouldDirty: true }
)
}
/>
<Label htmlFor={`${id}-required-${binding.fieldId}`}>
{t("required")}
<span className="sr-only">
{": "}
{field?.values.name ?? ""}
</span>
</Label>
<Button
aria-label={t("remove", { name: field?.values.name ?? "" })}
disabled={mutation.isPending}
onClick={() =>
form.setValue(
"fields",
selected.filter((entry) => entry.fieldId !== binding.fieldId),
{ shouldDirty: true }
)
}
size="sm"
type="button"
variant="ghost"
>
×
</Button>
</div>
</div>
);
})}
</div>
<CatalogError
error={mutation.error}
invalid={Object.keys(form.formState.errors).length > 0}
/>
<CatalogHelp kind="template" scope={scope} />
<FormActions>
<Button disabled={mutation.isPending} type="submit">
{t(mutation.isPending ? "saving" : "save")}
</Button>
</FormActions>
</form>
{creatingField && (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
setCreatingField(false);
}
}}
open
title={t("newField")}
>
<p className="text-sm text-muted-foreground">{t("quickNotice")}</p>
<CatalogFieldForm
csrfToken={csrfToken}
onSaved={(fieldId) => {
add(fieldId);
setCreatingField(false);
}}
scope={scope}
/>
</FormDialog>
)}
</>
);
}

View File

@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { Maybe } from "@repo/rdx-utils";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createInstance } from "i18next";
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nextProvider } from "react-i18next";
import { buildCatalogResponse } from "../api/application/resource-catalog-response.builder";
import { ResourceCatalog } from "../api/domain/resource-catalog.aggregate";
import { seedResourceCatalog } from "../api/domain/resource-catalog-seed";
import { catalogKeys } from "./catalog-api";
import { CatalogFieldForm } from "./catalog-form";
import { ResourceCatalogPage } from "./catalog-page";
import { CatalogTemplateForm } from "./catalog-template-form";
import { resourcesEnglish, resourcesSpanish } from "./i18n";
async function render(language: string, children: ReactNode) {
const i18n = createInstance();
await i18n.init({
lng: language,
resources: { es: { resources: resourcesSpanish }, en: { resources: resourcesEnglish } },
});
const client = new QueryClient();
client.setQueryData(
catalogKeys.scope("global"),
buildCatalogResponse(seedResourceCatalog(ResourceCatalog.create()), Maybe.none())
);
try {
return renderToStaticMarkup(
<I18nextProvider i18n={i18n}>
<QueryClientProvider client={client}>{children}</QueryClientProvider>
</I18nextProvider>
);
} finally {
client.clear();
}
}
test("el catálogo muestra plantillas de serie y acción de alta sin formularios junto al listado", async () => {
for (const [language, create, copy] of [
["es", "Nueva plantilla", "Crear copia"],
["en", "New template", "Create copy"],
]) {
assert.ok(language && create && copy);
const html = await render(language, <ResourceCatalogPage csrfToken="test" scope="global" />);
assert.ok(html.includes("Buzón de correo"));
assert.ok(html.includes(create));
assert.ok(html.includes(copy));
assert.equal(html.includes("<form"), false);
assert.equal(html.includes(">Editar</button>"), false);
}
});
test("los formularios tienen ayuda inferior y selector de campo con alta integrada", async () => {
const field = await render(
"es",
<CatalogFieldForm csrfToken="test" onSaved={() => undefined} scope="global" />
);
assert.ok(field.indexOf('name="description"') < field.indexOf("<aside"));
assert.ok(field.indexOf("<aside") < field.indexOf('type="submit"'));
assert.ok(field.includes("El tipo de dato queda fijo"));
const template = await render(
"es",
<CatalogTemplateForm csrfToken="test" items={[]} onSaved={() => undefined} scope="global" />
);
assert.ok(template.includes('aria-label="Nuevo campo"'));
assert.ok(template.includes("Todos son opcionales inicialmente"));
assert.ok(template.indexOf("<aside") < template.indexOf('type="submit"'));
});

View File

@ -0,0 +1,200 @@
import { inventoryEnglish, inventorySpanish } from "./inventory-i18n";
export const resourcesSpanish = {
...inventorySpanish,
yes: "Sí",
no: "No",
title: "Catálogo de recursos",
companyTitle: "Catálogo de recursos",
introduction: "Define las características y plantillas que utilizarás al registrar recursos.",
companyIntroduction: "Utiliza las definiciones globales y añade las que necesite esta compañía.",
fields: "Campos",
templates: "Plantillas",
newField: "Nuevo campo",
editField: "Editar campo",
newTemplate: "Nueva plantilla",
editTemplate: "Editar plantilla",
name: "Nombre",
description: "Descripción (opcional)",
type: "Tipo de dato",
unit: "Unidad (opcional)",
choices: "Valores de la lista",
choicesHelp:
"Escribe un valor por línea. Los valores existentes se conservan; puedes añadir otros.",
text: "Texto",
number: "Número",
date: "Fecha",
boolean: "Sí / No",
choice: "Lista de valores",
active: "Activo",
inactive: "Inactivo",
activate: "Activar",
deactivate: "Desactivar",
inherited: "Configuración global",
overridden: "Personalizado en esta compañía",
reset: "Usar configuración global",
builtin: "De serie",
global: "Global",
own: "De la compañía",
custom: "Personalizado",
origin: "Origen",
actions: "Acciones",
edit: "Editar",
copy: "Crear copia",
copyName: "{{name}} (copia)",
fieldCount: "{{count}} campos",
search: "Buscar",
empty: "No hay resultados.",
loading: "Cargando catálogo…",
retry: "Reintentar",
save: "Guardar",
saving: "Guardando…",
cancel: "Cancelar",
selectField: "Añadir un campo a la plantilla",
choose: "Selecciona un campo",
addField: "Nuevo campo",
required: "Obligatorio",
chooseType: "Selecciona un tipo",
view: "Ver",
close: "Cerrar",
viewDefinition: "{{name}}",
optional: "Opcional",
remove: "Retirar campo: {{name}}",
noFields: "Añade al menos un campo. Todos son opcionales inicialmente.",
protected:
"Las definiciones de serie están protegidas. Puedes cambiar su disponibilidad o crear una copia de una plantilla.",
helpTitle: "¿Qué es y para qué sirve?",
examplesLabel: "Ver ejemplos y recomendaciones",
fieldDescription:
"Un campo describe una característica que podrás rellenar en un recurso. Puedes reutilizarlo en varias plantillas.",
fieldExamples:
"Ejemplos: Dirección de correo (texto), Caducidad (fecha), URL (texto) o Proveedor (texto).",
fieldGuidance:
"Añade una unidad solo si resulta útil. No guardes contraseñas ni secretos. El tipo de dato queda fijo después del alta.",
templateDescription:
"Una plantilla reúne los campos que se mostrarán para un tipo de recurso. No representa un buzón, carpeta, aplicación o certificado concreto.",
templateExamples:
"Buzón: dirección y proveedor. Carpeta compartida: ruta UNC. Certificado: titular, emisor y caducidad.",
templateGuidance:
"Las características son opcionales por defecto. Marca como obligatorio solo lo que siempre se conozca. Retirar un campo de una plantilla no elimina el campo del catálogo.",
globalHelp: "Ámbito global: disponible para todas las compañías.",
companyHelp:
"Ámbito de compañía: estas adiciones y cambios de disponibilidad solo afectan a esta compañía.",
disabledField: "Campo inactivo: se conserva en esta plantilla.",
disabledFieldsNotice:
"Esta plantilla contiene campos inactivos. Puedes activarlos en el catálogo antes de utilizarla.",
quickNotice: "El campo se guardará aunque después canceles la plantilla.",
errors: {
INVALID_REQUEST: "Revisa los datos del formulario.",
INVALID_CHOICES:
"Una lista necesita valores únicos y no vacíos. Los demás tipos no admiten valores de lista.",
INVALID_FIELDS: "La plantilla necesita campos distintos y válidos.",
NAME_EXISTS: "Ya existe una definición de este tipo con ese nombre.",
FIELD_UNAVAILABLE:
"Solo puedes añadir campos activos de recursos del catálogo global o de esta compañía.",
FIELD_TYPE_FIXED:
"El tipo de dato no se puede cambiar ni se pueden eliminar valores existentes de una lista.",
BUILTIN_PROTECTED: "Las definiciones de serie no se pueden modificar.",
DEFINITION_NOT_FOUND: "La definición no está disponible en este ámbito.",
COMPANY_NOT_FOUND: "No se ha encontrado la compañía.",
INTERNAL_ERROR: "No se ha podido completar la operación.",
FORBIDDEN: "La sesión no permite esta operación. Vuelve a iniciar sesión.",
UNAUTHENTICATED: "Vuelve a iniciar sesión.",
},
};
export const resourcesEnglish = {
...inventoryEnglish,
yes: "Yes",
no: "No",
title: "Resource catalog",
companyTitle: "Resource catalog",
introduction: "Define the characteristics and templates used when registering resources.",
companyIntroduction: "Use global definitions and add those needed by this company.",
fields: "Fields",
templates: "Templates",
newField: "New field",
editField: "Edit field",
newTemplate: "New template",
editTemplate: "Edit template",
name: "Name",
description: "Description (optional)",
type: "Data type",
unit: "Unit (optional)",
choices: "List values",
choicesHelp: "Enter one value per line. Existing values are retained; you can add others.",
text: "Text",
number: "Number",
date: "Date",
boolean: "Yes / No",
choice: "List of values",
active: "Active",
inactive: "Inactive",
activate: "Activate",
deactivate: "Deactivate",
inherited: "Global configuration",
overridden: "Customized for this company",
reset: "Use global configuration",
builtin: "Built in",
global: "Global",
own: "Company",
custom: "Custom",
origin: "Origin",
actions: "Actions",
edit: "Edit",
copy: "Create copy",
copyName: "{{name}} (copy)",
fieldCount: "{{count}} fields",
search: "Search",
empty: "No results.",
loading: "Loading catalog…",
retry: "Retry",
save: "Save",
saving: "Saving…",
cancel: "Cancel",
selectField: "Add a field to the template",
choose: "Select a field",
addField: "New field",
required: "Required",
chooseType: "Select a type",
view: "View",
close: "Close",
viewDefinition: "{{name}}",
optional: "Optional",
remove: "Remove field: {{name}}",
noFields: "Add at least one field. All fields are initially optional.",
protected:
"Built-in definitions are protected. You can change their availability or create a template copy.",
helpTitle: "What is it for?",
examplesLabel: "Show examples and guidance",
fieldDescription:
"A field describes an resource characteristic. You can reuse it in several templates.",
fieldExamples: "Examples: Email address (text), Expiry (date), URL (text) or Provider (text).",
fieldGuidance:
"Add a unit only when useful. Never store passwords or secrets. The data type is fixed after creation.",
templateDescription:
"A template groups the fields displayed for an resource type. It is not a specific mailbox, folder, application or certificate.",
templateExamples:
"Mailbox: address and provider. Shared folder: UNC path. Certificate: holder, issuer and expiry.",
templateGuidance:
"Characteristics are optional by default. Require only information that is always known. Removing a template field does not delete its catalog definition.",
globalHelp: "Global scope: available to all companies.",
companyHelp: "Company scope: additions and availability changes affect this company only.",
disabledField: "Inactive field: retained in this template.",
disabledFieldsNotice:
"This template contains inactive fields. You can activate them in the catalog before using it.",
quickNotice: "The field remains saved if you later cancel the template.",
errors: {
INVALID_REQUEST: "Check the form data.",
INVALID_CHOICES:
"A list needs unique non-empty values. Other data types do not accept list values.",
INVALID_FIELDS: "The template needs distinct valid fields.",
NAME_EXISTS: "A definition of this type already has this name.",
FIELD_UNAVAILABLE: "Only active global or company fields can be added.",
FIELD_TYPE_FIXED: "The data type cannot change and existing list values cannot be removed.",
BUILTIN_PROTECTED: "Built-in definitions cannot be edited.",
DEFINITION_NOT_FOUND: "This definition is unavailable in this scope.",
COMPANY_NOT_FOUND: "Company not found.",
INTERNAL_ERROR: "The operation could not be completed.",
FORBIDDEN: "Your session does not allow this operation. Sign in again.",
UNAUTHENTICATED: "Sign in again.",
},
};

View File

@ -0,0 +1,3 @@
export { ResourceCatalogPage } from "./catalog-page";
export { resourcesEnglish, resourcesSpanish } from "./i18n";
export { ResourcesPage } from "./resources-page";

View File

@ -0,0 +1,37 @@
import { z } from "zod/v4";
import type { ResourceInput } from "../common/inventory";
import { InventoryResponseSchema, ResourceResponseSchema } from "../common/inventory";
export const inventoryKeys = { company: (id: string) => ["resource-inventory", id] };
export class InventoryApiError extends Error {
constructor(public readonly code: string) {
super(code);
}
}
async function request(path: string, body?: unknown, csrfToken?: string) {
const response = await fetch(`/api/resources/${path}`, {
method: body === undefined ? "GET" : "POST",
credentials: "same-origin",
headers:
body === undefined
? {}
: { "content-type": "application/json", "x-csrf-token": csrfToken ?? "" },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const data: unknown = await response.json();
if (!response.ok) {
const error = z.object({ code: z.string() }).safeParse(data);
throw new InventoryApiError(error.success ? error.data.code : "INTERNAL_ERROR");
}
return data;
}
export const inventoryApi = {
async get(companyId: string) {
return InventoryResponseSchema.parse(await request(companyId));
},
async save(companyId: string, values: ResourceInput, token: string, id?: string) {
return ResourceResponseSchema.parse(
await request(`${companyId}${id ? `/${id}/update` : ""}`, { values }, token)
);
},
};

View File

@ -0,0 +1,81 @@
export const inventorySpanish = {
inventory: "Recursos",
newResource: "Nuevo recurso",
editResource: "Editar recurso",
viewResource: "Ficha e historial",
template: "Plantilla de recurso",
status: "Estado",
active: "Activo",
inactive: "Inactivo",
notes: "Notas (opcional)",
characteristics: "Características",
chooseValue: "Selecciona un valor",
noValue: "Sin datos",
history: "Historial",
recordedBy: "Registrado por {{name}}",
snapshotNotice:
"La ficha conserva los campos de la plantilla utilizados al dar de alta el recurso.",
resourceDescription:
"Registra un buzón, carpeta compartida, aplicación o certificado disponible en el contexto de esta compañía.",
resourceExamples:
"Ejemplos: administracion@empresa.es, carpeta Administración, Zoom corporativo o certificado de firma.",
resourceGuidance:
"Las características son opcionales salvo que la plantilla indique lo contrario. No registres contraseñas ni claves privadas.",
resourceScope:
"Este registro identifica el recurso de la compañía. Su disponibilidad y su configuración en los equipos se gestionan por separado.",
relatedNotice: "La plantilla se guardará aunque después canceles el recurso.",
inventoryErrors: {
INVALID_REQUEST: "Revisa los datos del formulario.",
INVALID_CHARACTERISTICS:
"Una característica no pertenece a la plantilla o tiene un valor inválido.",
REQUIRED_CHARACTERISTICS: "Completa las características obligatorias de la plantilla.",
TEMPLATE_FIXED: "La plantilla de un recurso no puede cambiar.",
TEMPLATE_UNAVAILABLE: "Selecciona una plantilla activa de recursos de este ámbito.",
NAME_EXISTS: "Ya existe un recurso con ese nombre y plantilla en esta compañía.",
RESOURCE_NOT_FOUND: "No se ha encontrado el recurso.",
COMPANY_NOT_FOUND: "No se ha encontrado la compañía.",
FORBIDDEN: "La sesión no permite esta operación. Vuelve a iniciar sesión.",
UNAUTHENTICATED: "Vuelve a iniciar sesión.",
INTERNAL_ERROR: "No se ha podido completar la operación.",
},
};
export const inventoryEnglish = {
inventory: "Resources",
newResource: "New resource",
editResource: "Edit resource",
viewResource: "Details and history",
template: "Resource template",
status: "Status",
active: "Active",
inactive: "Inactive",
notes: "Notes (optional)",
characteristics: "Characteristics",
chooseValue: "Select a value",
noValue: "No data",
history: "History",
recordedBy: "Recorded by {{name}}",
snapshotNotice: "This record retains the template fields captured when the resource was created.",
resourceDescription:
"Register a mailbox, shared folder, application or certificate used in this company's context.",
resourceExamples:
"Examples: admin@company.com, Administration folder, corporate Zoom or signing certificate.",
resourceGuidance:
"Characteristics are optional unless the template requires them. Never store passwords or private keys.",
resourceScope:
"This record identifies the company resource. Availability and device configuration are managed separately.",
relatedNotice: "The template remains saved if you later cancel the resource.",
inventoryErrors: {
INVALID_REQUEST: "Check the form data.",
INVALID_CHARACTERISTICS:
"A characteristic is not part of the template or has an invalid value.",
REQUIRED_CHARACTERISTICS: "Complete the required template characteristics.",
TEMPLATE_FIXED: "A resource's template cannot change.",
TEMPLATE_UNAVAILABLE: "Select an active resource template from this scope.",
NAME_EXISTS: "A resource with this name and template already exists in this company.",
RESOURCE_NOT_FOUND: "Resource not found.",
COMPANY_NOT_FOUND: "Company not found.",
FORBIDDEN: "Your session does not allow this operation. Sign in again.",
UNAUTHENTICATED: "Sign in again.",
INTERNAL_ERROR: "The operation could not be completed.",
},
};

View File

@ -0,0 +1,85 @@
import { Input } from "@repo/shadcn-ui/components/input";
import { Label } from "@repo/shadcn-ui/components/label";
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
import { useTranslation } from "react-i18next";
import type { ResourceCharacteristic, ResourceInput } from "../common/inventory";
type CharacteristicValue = ResourceInput["characteristics"][string];
export function ResourceCharacteristicControl({
field,
value,
onChange,
prefix,
disabled,
}: {
field: ResourceCharacteristic;
value: CharacteristicValue | undefined;
onChange: (value: CharacteristicValue | undefined) => void;
prefix: string;
disabled: boolean;
}) {
const { t } = useTranslation("resources");
const id = `${prefix}-${field.fieldId}`;
const label = `${field.name}${field.unit ? ` (${field.unit})` : ""}${field.required ? " *" : ""}`;
return (
<div className="space-y-2">
<Label htmlFor={id}>{label}</Label>
{field.type === "choice" || field.type === "boolean" ? (
<SelectWithAction
disabled={disabled}
id={id}
label={label}
onValueChange={(choice) =>
onChange(
choice === "" ? undefined : field.type === "boolean" ? choice === "true" : choice
)
}
options={
field.type === "boolean"
? [
{ value: "true", label: t("yes") },
{ value: "false", label: t("no") },
]
: field.choices.map((choice) => ({ value: choice, label: choice }))
}
placeholder={t("noValue")}
required={field.required}
value={value === undefined ? "" : String(value)}
/>
) : (
<Input
disabled={disabled}
id={id}
maxLength={10000}
onChange={(event) => {
const input = event.currentTarget.value;
onChange(input === "" ? undefined : field.type === "number" ? Number(input) : input);
}}
onInput={
field.type === "date"
? (event) => {
const input = event.currentTarget.value;
onChange(input || undefined);
}
: undefined
}
required={field.required}
step={field.type === "number" ? "any" : undefined}
type={inputType(field.type)}
value={value === undefined ? "" : String(value)}
/>
)}
</div>
);
}
function inputType(type: ResourceCharacteristic["type"]) {
if (type === "number") {
return "number";
}
if (type === "date") {
return "date";
}
return "text";
}

View File

@ -0,0 +1,74 @@
import { useTranslation } from "react-i18next";
import type { ResourceDTO } from "../common/inventory";
export function ResourceDetail({ resource }: { resource: ResourceDTO }) {
const { t, i18n } = useTranslation("resources");
const display = (value: string | number | boolean | undefined) =>
value === undefined
? t("noValue")
: typeof value === "boolean"
? t(value ? "yes" : "no")
: String(value);
return (
<div className="space-y-5">
<p className="text-sm text-muted-foreground">{t("snapshotNotice")}</p>
<dl className="grid gap-4 sm:grid-cols-2">
<div>
<dt className="text-sm text-muted-foreground">{t("template")}</dt>
<dd>{resource.specification.templateName}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">{t("status")}</dt>
<dd>{t(resource.values.active ? "active" : "inactive")}</dd>
</div>
{resource.specification.fields.map((field) => (
<div key={field.fieldId}>
<dt className="text-sm text-muted-foreground">
{field.name}
{field.unit ? ` (${field.unit})` : ""}
</dt>
<dd className="whitespace-pre-wrap break-words">
{display(resource.values.characteristics[field.fieldId])}
</dd>
</div>
))}
</dl>
{resource.values.notes && (
<p className="whitespace-pre-wrap break-words">{resource.values.notes}</p>
)}
<h3 className="font-medium">
{t("history")} ({resource.history.length})
</h3>
<ol className="space-y-4">
{[...resource.history].reverse().map((revision) => (
<li className="space-y-2 rounded-md border p-3 text-sm" key={revision.id}>
<p className="font-medium">
{new Intl.DateTimeFormat(i18n.language, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(revision.recordedAt))}{" "}
· {t("recordedBy", { name: revision.actorName })}
</p>
<p>
{revision.values.name} · {t(revision.values.active ? "active" : "inactive")}
</p>
<dl className="grid gap-2 sm:grid-cols-2">
{resource.specification.fields.map((field) => (
<div key={field.fieldId}>
<dt className="text-muted-foreground">{field.name}</dt>
<dd className="whitespace-pre-wrap break-words">
{display(revision.values.characteristics[field.fieldId])}
</dd>
</div>
))}
</dl>
{revision.values.notes && (
<p className="whitespace-pre-wrap break-words">{revision.values.notes}</p>
)}
</li>
))}
</ol>
</div>
);
}

View File

@ -0,0 +1,242 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@repo/shadcn-ui/components/button";
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
import { FormActions, FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { Input } from "@repo/shadcn-ui/components/input";
import { Label } from "@repo/shadcn-ui/components/label";
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
import { Textarea } from "@repo/shadcn-ui/components/textarea";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useEffect, useId, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { CatalogItemDTO } from "../common";
import type { ResourceDTO, ResourceInput } from "../common/inventory";
import { ResourceInputSchema } from "../common/inventory";
import { CatalogTemplateForm } from "./catalog-template-form";
import { InventoryApiError, inventoryApi, inventoryKeys } from "./inventory-api";
import { ResourceCharacteristicControl } from "./resource-characteristic-control";
export function InventoryFeedback({ error }: { error: Error | null }) {
const { t } = useTranslation("resources");
return error ? (
<p className="text-sm text-destructive" role="alert">
{t(`inventoryErrors.${error instanceof InventoryApiError ? error.code : "INTERNAL_ERROR"}`)}
</p>
) : null;
}
export function ResourceForm({
companyId,
csrfToken,
resource,
items,
onSaved,
allowQuickCreate = true,
}: {
companyId: string;
csrfToken: string;
resource?: ResourceDTO | undefined;
items: readonly CatalogItemDTO[];
onSaved: (id: string) => void;
allowQuickCreate?: boolean;
}) {
const { t } = useTranslation("resources");
const prefix = useId();
const client = useQueryClient();
const [creatingTemplate, setCreatingTemplate] = useState(false);
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const form = useForm<ResourceInput>({
resolver: zodResolver(ResourceInputSchema),
defaultValues: resource?.values ?? {
name: "",
templateId: "",
active: true,
notes: "",
characteristics: {},
},
});
const values = form.watch();
const templates = items.filter((item) => item.values.kind === "template" && item.effectiveActive);
const template = templates.find((item) => item.id === values.templateId);
const fields =
resource?.specification.fields ??
(template?.values.kind === "template"
? template.values.fields.flatMap((binding) => {
const field = items.find((item) => item.id === binding.fieldId);
return field?.values.kind === "field"
? [
{
fieldId: field.id,
name: field.values.name,
type: field.values.type,
unit: field.values.unit,
choices: field.values.choices,
required: binding.required,
},
]
: [];
})
: []);
const mutation = useMutation({
mutationFn: (input: ResourceInput) =>
inventoryApi.save(companyId, input, csrfToken, resource?.id),
onSuccess: async (response) => {
await client.invalidateQueries({ queryKey: inventoryKeys.company(companyId) });
if (mounted.current) {
onSaved(response.resource.id);
}
},
});
const setCharacteristic = (
fieldId: string,
value: ResourceInput["characteristics"][string] | undefined
) => {
const characteristics = { ...form.getValues("characteristics") };
if (value === undefined) {
delete characteristics[fieldId];
} else {
characteristics[fieldId] = value;
}
form.setValue("characteristics", characteristics, { shouldDirty: true });
};
return (
<>
<form className="space-y-5" onSubmit={form.handleSubmit((input) => mutation.mutate(input))}>
<fieldset className="space-y-5" disabled={mutation.isPending}>
<div className="space-y-2">
<Label htmlFor={`${prefix}-name`}>{t("name")}</Label>
<Input id={`${prefix}-name`} maxLength={100} required {...form.register("name")} />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`${prefix}-template`}>{t("template")}</Label>
<SelectWithAction
action={
resource || !allowQuickCreate
? undefined
: { label: t("newTemplate"), onClick: () => setCreatingTemplate(true) }
}
disabled={!!resource || mutation.isPending}
id={`${prefix}-template`}
label={t("template")}
onValueChange={(templateId) => {
form.setValue("templateId", templateId);
form.setValue("characteristics", {});
}}
options={
resource
? [
{
value: resource.values.templateId,
label: resource.specification.templateName,
},
]
: templates.map((item) => ({ value: item.id, label: item.values.name }))
}
placeholder={t("chooseValue")}
required
value={values.templateId}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-status`}>{t("status")}</Label>
<SelectWithAction
disabled={mutation.isPending}
id={`${prefix}-status`}
label={t("status")}
onValueChange={(value) => {
if (value === "active" || value === "inactive") {
form.setValue("active", value === "active");
}
}}
options={["active", "inactive"].map((value) => ({ value, label: t(value) }))}
placeholder={t("chooseValue")}
required
value={values.active ? "active" : "inactive"}
/>
</div>
</div>
{fields.length > 0 && (
<section className="space-y-3">
<h3 className="font-medium">{t("characteristics")}</h3>
<div className="grid gap-4 sm:grid-cols-2">
{fields.map((field) => (
<ResourceCharacteristicControl
disabled={mutation.isPending}
field={field}
key={field.fieldId}
onChange={(value) => setCharacteristic(field.fieldId, value)}
prefix={prefix}
value={values.characteristics[field.fieldId]}
/>
))}
</div>
</section>
)}
<div className="space-y-2">
<Label htmlFor={`${prefix}-notes`}>{t("notes")}</Label>
<Textarea
id={`${prefix}-notes`}
maxLength={10000}
rows={3}
{...form.register("notes")}
/>
</div>
</fieldset>
{Object.keys(form.formState.errors).length > 0 && (
<p className="text-sm text-destructive" role="alert">
{t("inventoryErrors.INVALID_REQUEST")}
</p>
)}
<InventoryFeedback error={mutation.error} />
<ContextHelp
description={t("resourceDescription")}
examples={t("resourceExamples")}
examplesLabel={t("examplesLabel")}
guidance={t("resourceGuidance")}
scopeNote={t("resourceScope")}
title={t("helpTitle")}
/>
<FormActions>
<Button disabled={mutation.isPending} type="submit">
{t(mutation.isPending ? "saving" : "save")}
</Button>
</FormActions>
</form>
{creatingTemplate && (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
setCreatingTemplate(false);
}
}}
open
title={t("newTemplate")}
wide
>
<p className="text-sm text-muted-foreground">{t("relatedNotice")}</p>
<CatalogTemplateForm
allowQuickCreate={false}
csrfToken={csrfToken}
items={items}
onSaved={(id) => {
form.setValue("templateId", id);
form.setValue("characteristics", {});
setCreatingTemplate(false);
}}
scope={companyId}
/>
</FormDialog>
)}
</>
);
}

View File

@ -0,0 +1,140 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { Input } from "@repo/shadcn-ui/components/input";
import { Label } from "@repo/shadcn-ui/components/label";
import { useQuery } from "@tanstack/react-query";
import { useId, useState } from "react";
import { useTranslation } from "react-i18next";
import { catalogApi, catalogKeys } from "./catalog-api";
import { inventoryApi, inventoryKeys } from "./inventory-api";
import { ResourceDetail } from "./resource-detail";
import { InventoryFeedback, ResourceForm } from "./resource-form";
export function ResourcesPage({ companyId, csrfToken }: { companyId: string; csrfToken: string }) {
const { t } = useTranslation("resources");
const searchId = useId();
const [search, setSearch] = useState("");
const [editor, setEditor] = useState<string>();
const [viewId, setViewId] = useState<string>();
const query = useQuery({
queryKey: inventoryKeys.company(companyId),
queryFn: () => inventoryApi.get(companyId),
});
const catalog = useQuery({
queryKey: catalogKeys.scope(companyId),
queryFn: () => catalogApi.get(companyId),
});
const edited = query.data?.resources.find((item) => item.id === editor);
const viewed = query.data?.resources.find((item) => item.id === viewId);
const visible =
query.data?.resources.filter((item) =>
`${item.values.name} ${item.specification.templateName}`
.toLocaleLowerCase()
.includes(search.toLocaleLowerCase())
) ?? [];
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<h2 className="text-2xl font-semibold">{t("inventory")}</h2>
<Button disabled={!(query.data && catalog.data)} onClick={() => setEditor("new")}>
{t("newResource")}
</Button>
</div>
<p className="text-sm text-muted-foreground">{t("resourceScope")}</p>
{(query.isPending || catalog.isPending) && <p role="status">{t("loading")}</p>}
<InventoryFeedback error={query.error ?? catalog.error} />
{(query.isError || catalog.isError) && (
<Button
onClick={() => {
Promise.all([query.refetch(), catalog.refetch()]).catch(() => undefined);
}}
variant="outline"
>
{t("retry")}
</Button>
)}
<Card>
<CardContent className="space-y-4 pt-6">
<Label htmlFor={searchId}>{t("search")}</Label>
<Input id={searchId} onChange={(event) => setSearch(event.target.value)} value={search} />
{query.data &&
(visible.length ? (
<div className="divide-y">
{visible.map((item) => (
<div
className="flex flex-wrap items-center justify-between gap-4 py-4"
key={item.id}
>
<div>
<h3 className="font-medium">{item.values.name}</h3>
<p className="text-sm text-muted-foreground">
{item.specification.templateName} ·{" "}
{t(item.values.active ? "active" : "inactive")}
</p>
</div>
<div className="flex gap-2">
<Button onClick={() => setViewId(item.id)} size="sm" variant="outline">
{t("viewResource")}
</Button>
<Button
disabled={!catalog.data}
onClick={() => setEditor(item.id)}
size="sm"
variant="outline"
>
{t("edit")}
</Button>
</div>
</div>
))}
</div>
) : (
<p className="text-muted-foreground">{t("empty")}</p>
))}
</CardContent>
</Card>
{editor && catalog.data && (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
setEditor(undefined);
}
}}
open
title={t(edited ? "editResource" : "newResource")}
wide
>
<ResourceForm
companyId={companyId}
csrfToken={csrfToken}
items={catalog.data.items}
onSaved={(id) => {
setEditor(undefined);
setViewId(id);
}}
resource={edited}
/>
</FormDialog>
)}
{viewed && (
<FormDialog
cancelLabel={t("close")}
onOpenChange={(open) => {
if (!open) {
setViewId(undefined);
}
}}
open
title={viewed.values.name}
wide
>
<ResourceDetail resource={viewed} />
</FormDialog>
)}
</div>
);
}

View File

@ -0,0 +1,77 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { UniqueID } from "@repo/rdx-ddd";
import { Maybe } from "@repo/rdx-utils";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createInstance } from "i18next";
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nextProvider } from "react-i18next";
import { buildCatalogResponse } from "../api/application/resource-catalog-response.builder";
import { ResourceCatalog } from "../api/domain/resource-catalog.aggregate";
import { seedResourceCatalog } from "../api/domain/resource-catalog-seed";
import { catalogKeys } from "./catalog-api";
import { resourcesEnglish, resourcesSpanish } from "./i18n";
import { inventoryKeys } from "./inventory-api";
import { ResourceForm } from "./resource-form";
import { ResourcesPage } from "./resources-page";
const companyId = UniqueID.generateNewID().toPrimitive();
async function render(language: string, children: ReactNode) {
const i18n = createInstance();
await i18n.init({
lng: language,
resources: { es: { resources: resourcesSpanish }, en: { resources: resourcesEnglish } },
});
const client = new QueryClient();
client.setQueryData(
catalogKeys.scope(companyId),
buildCatalogResponse(seedResourceCatalog(ResourceCatalog.create()), Maybe.none())
);
client.setQueryData(inventoryKeys.company(companyId), { resources: [] });
try {
return renderToStaticMarkup(
<I18nextProvider i18n={i18n}>
<QueryClientProvider client={client}>{children}</QueryClientProvider>
</I18nextProvider>
);
} finally {
client.clear();
}
}
test("recursos: listado con alta modal y sin formulario visible, en español e inglés", async () => {
for (const [language, label] of [
["es", "Nuevo recurso"],
["en", "New resource"],
]) {
assert.ok(language && label);
const html = await render(language, <ResourcesPage companyId={companyId} csrfToken="test" />);
assert.ok(html.includes(label));
assert.equal(html.includes("<form"), false);
}
});
test("recurso: ayuda inferior, plantilla con alta rápida y sin asignaciones de recursos", async () => {
const html = await render(
"es",
<ResourceForm companyId={companyId} csrfToken="test" items={[]} onSaved={() => undefined} />
);
assert.ok(html.includes('aria-label="Nueva plantilla"'));
assert.ok(html.indexOf('name="notes"') < html.indexOf("<aside"));
assert.ok(html.indexOf("<aside") < html.indexOf('type="submit"'));
assert.equal(html.includes('name="personId"'), false);
assert.equal(html.includes('name="siteId"'), false);
const child = await render(
"es",
<ResourceForm
allowQuickCreate={false}
companyId={companyId}
csrfToken="test"
items={[]}
onSaved={() => undefined}
/>
);
assert.equal(child.includes('aria-label="Nueva plantilla"'), false);
});

View File

@ -0,0 +1,9 @@
{
"extends": "@repo/typescript-config/buildless.json",
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["node"]
},
"include": ["src"]
}

View File

@ -38,6 +38,9 @@ importers:
'@metis/identity':
specifier: workspace:*
version: link:../../modules/identity
'@metis/resources':
specifier: workspace:*
version: link:../../modules/resources
connect-session-sequelize:
specifier: 8.0.6
version: 8.0.6(sequelize@6.37.8(mariadb@3.5.4))
@ -87,6 +90,9 @@ importers:
'@metis/identity':
specifier: workspace:*
version: link:../../modules/identity
'@metis/resources':
specifier: workspace:*
version: link:../../modules/resources
'@repo/shadcn-ui':
specifier: workspace:*
version: link:../../packages/shadcn-ui
@ -197,6 +203,9 @@ importers:
'@metis/assets':
specifier: workspace:*
version: link:../assets
'@metis/resources':
specifier: workspace:*
version: link:../resources
'@repo/rdx-ddd':
specifier: workspace:*
version: link:../../packages/rdx-ddd
@ -360,6 +369,64 @@ importers:
specifier: 19.2.5
version: 19.2.5(react@19.2.5)
modules/resources:
dependencies:
'@hookform/resolvers':
specifier: 5.2.2
version: 5.2.2(react-hook-form@7.72.1(react@19.2.5))
'@repo/rdx-ddd':
specifier: workspace:*
version: link:../../packages/rdx-ddd
'@repo/rdx-utils':
specifier: workspace:*
version: link:../../packages/rdx-utils
'@repo/shadcn-ui':
specifier: workspace:*
version: link:../../packages/shadcn-ui
'@tanstack/react-query':
specifier: 5.98.0
version: 5.98.0(react@19.2.5)
express:
specifier: 4.22.1
version: 4.22.1
i18next:
specifier: 26.0.4
version: 26.0.4(typescript@6.0.2)
react:
specifier: 19.2.5
version: 19.2.5
react-dom:
specifier: 19.2.5
version: 19.2.5(react@19.2.5)
react-hook-form:
specifier: 7.72.1
version: 7.72.1(react@19.2.5)
react-i18next:
specifier: 17.0.2
version: 17.0.2(i18next@26.0.4(typescript@6.0.2))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.2)
sequelize:
specifier: 6.37.8
version: 6.37.8(mariadb@3.5.4)
zod:
specifier: 4.3.6
version: 4.3.6
devDependencies:
'@repo/typescript-config':
specifier: workspace:*
version: link:../../packages/typescript-config
'@types/express':
specifier: 4.17.21
version: 4.17.21
'@types/node':
specifier: 25.6.0
version: 25.6.0
'@types/react':
specifier: 19.2.14
version: 19.2.14
'@types/react-dom':
specifier: 19.2.3
version: 19.2.3(@types/react@19.2.14)
packages/rdx-ddd:
dependencies:
'@repo/rdx-utils':