Incorporar recursos
This commit is contained in:
parent
6ab7e8f846
commit
03c1c30146
@ -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)
|
||||
|
||||
@ -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:*",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
},
|
||||
|
||||
257
apps/server/src/resources.integration.ts
Normal file
257
apps/server/src/resources.integration.ts
Normal 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);
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
62
docs/recursos-campos-y-plantillas.md
Normal file
62
docs/recursos-campos-y-plantillas.md
Normal 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.
|
||||
@ -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:*",
|
||||
|
||||
@ -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" />;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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} />;
|
||||
}
|
||||
|
||||
39
modules/resources/package.json
Normal file
39
modules/resources/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@ -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>>;
|
||||
}
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
21
modules/resources/src/api/application/resource.repository.ts
Normal file
21
modules/resources/src/api/application/resource.repository.ts
Normal 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>
|
||||
>;
|
||||
}
|
||||
60
modules/resources/src/api/application/resource.use-cases.ts
Normal file
60
modules/resources/src/api/application/resource.use-cases.ts
Normal 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
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
55
modules/resources/src/api/domain/resource-catalog-seed.ts
Normal file
55
modules/resources/src/api/domain/resource-catalog-seed.ts
Normal 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;
|
||||
}
|
||||
184
modules/resources/src/api/domain/resource-catalog.aggregate.ts
Normal file
184
modules/resources/src/api/domain/resource-catalog.aggregate.ts
Normal 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();
|
||||
}
|
||||
112
modules/resources/src/api/domain/resource-catalog.test.ts
Normal file
112
modules/resources/src/api/domain/resource-catalog.test.ts
Normal 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);
|
||||
});
|
||||
133
modules/resources/src/api/domain/resource-definition.entity.ts
Normal file
133
modules/resources/src/api/domain/resource-definition.entity.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
28
modules/resources/src/api/domain/resource-definition.ts
Normal file
28
modules/resources/src/api/domain/resource-definition.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
25
modules/resources/src/api/domain/resource-revision.entity.ts
Normal file
25
modules/resources/src/api/domain/resource-revision.entity.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
32
modules/resources/src/api/domain/resource-values.ts
Normal file
32
modules/resources/src/api/domain/resource-values.ts
Normal 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>;
|
||||
70
modules/resources/src/api/domain/resource.aggregate.ts
Normal file
70
modules/resources/src/api/domain/resource.aggregate.ts
Normal 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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
108
modules/resources/src/api/domain/resource.test.ts
Normal file
108
modules/resources/src/api/domain/resource.test.ts
Normal 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");
|
||||
});
|
||||
@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
45
modules/resources/src/api/module.ts
Normal file
45
modules/resources/src/api/module.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
30
modules/resources/src/common/index.ts
Normal file
30
modules/resources/src/common/index.ts
Normal 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";
|
||||
34
modules/resources/src/common/inventory.ts
Normal file
34
modules/resources/src/common/inventory.ts
Normal 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>;
|
||||
42
modules/resources/src/web/catalog-api.ts
Normal file
42
modules/resources/src/web/catalog-api.ts
Normal 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)
|
||||
);
|
||||
},
|
||||
};
|
||||
85
modules/resources/src/web/catalog-definition-dialog.tsx
Normal file
85
modules/resources/src/web/catalog-definition-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
166
modules/resources/src/web/catalog-form.tsx
Normal file
166
modules/resources/src/web/catalog-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
modules/resources/src/web/catalog-help.tsx
Normal file
15
modules/resources/src/web/catalog-help.tsx
Normal 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")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
303
modules/resources/src/web/catalog-page.tsx
Normal file
303
modules/resources/src/web/catalog-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
198
modules/resources/src/web/catalog-template-form.tsx
Normal file
198
modules/resources/src/web/catalog-template-form.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
71
modules/resources/src/web/catalog.test.tsx
Normal file
71
modules/resources/src/web/catalog.test.tsx
Normal 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"'));
|
||||
});
|
||||
200
modules/resources/src/web/i18n.ts
Normal file
200
modules/resources/src/web/i18n.ts
Normal 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.",
|
||||
},
|
||||
};
|
||||
3
modules/resources/src/web/index.ts
Normal file
3
modules/resources/src/web/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { ResourceCatalogPage } from "./catalog-page";
|
||||
export { resourcesEnglish, resourcesSpanish } from "./i18n";
|
||||
export { ResourcesPage } from "./resources-page";
|
||||
37
modules/resources/src/web/inventory-api.ts
Normal file
37
modules/resources/src/web/inventory-api.ts
Normal 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)
|
||||
);
|
||||
},
|
||||
};
|
||||
81
modules/resources/src/web/inventory-i18n.ts
Normal file
81
modules/resources/src/web/inventory-i18n.ts
Normal 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.",
|
||||
},
|
||||
};
|
||||
@ -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";
|
||||
}
|
||||
74
modules/resources/src/web/resource-detail.tsx
Normal file
74
modules/resources/src/web/resource-detail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
242
modules/resources/src/web/resource-form.tsx
Normal file
242
modules/resources/src/web/resource-form.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
140
modules/resources/src/web/resources-page.tsx
Normal file
140
modules/resources/src/web/resources-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
modules/resources/src/web/resources.test.tsx
Normal file
77
modules/resources/src/web/resources.test.tsx
Normal 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);
|
||||
});
|
||||
9
modules/resources/tsconfig.json
Normal file
9
modules/resources/tsconfig.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@repo/typescript-config/buildless.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@ -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':
|
||||
|
||||
Loading…
Reference in New Issue
Block a user