From 867d5ec9c1f2ef552975345a29931fbffa720dce Mon Sep 17 00:00:00 2001 From: David Date: Sun, 4 Oct 2026 21:04:14 +0200 Subject: [PATCH] Puestos de trabajo --- apps/server/src/assets.integration.ts | 2 +- apps/server/src/identity.test.ts | 28 ++ apps/server/src/relationships.integration.ts | 155 ++++++++++ apps/server/src/workstations.integration.ts | 136 +++++++++ apps/web/src/app.test.tsx | 2 + .../modelo-ddd-e-identificadores.md | 4 + docs/campos-y-plantillas-de-activos.md | 8 +- docs/inventario-de-activos.md | 2 +- docs/plan-de-desarrollo.md | 11 +- docs/puestos-de-trabajo.md | 33 +++ docs/relaciones-entre-activos.md | 29 ++ modules/assets/package.json | 2 +- .../asset-relationship-response.builder.ts | 19 ++ .../asset-relationship.use-cases.ts | 80 ++++++ .../src/api/application/asset.repository.ts | 19 ++ .../application/workstation-input.mapper.ts | 47 +++ .../workstation-response.builder.ts | 19 ++ .../api/application/workstation.use-cases.ts | 36 +++ .../src/api/domain/asset-catalog-seed.ts | 9 + .../src/api/domain/asset-catalog.test.ts | 4 +- .../src/api/domain/asset-definition.entity.ts | 7 + .../assets/src/api/domain/asset-definition.ts | 7 + .../domain/asset-relationship.aggregate.ts | 78 +++++ .../src/api/domain/asset-relationship.test.ts | 124 ++++++++ .../value-objects/asset-definition-values.ts | 24 ++ .../asset-relationship-values.ts | 92 ++++++ .../value-objects/workstation-values.ts | 67 +++++ .../api/domain/workstation-revision.entity.ts | 25 ++ .../src/api/domain/workstation-values.ts | 9 + .../src/api/domain/workstation.aggregate.ts | 72 +++++ .../assets/src/api/domain/workstation.test.ts | 72 +++++ .../http/asset-relationship.routes.ts | 81 ++++++ .../api/infrastructure/http/asset.routes.ts | 8 + .../infrastructure/http/workstation.routes.ts | 58 ++++ .../asset-relationship-persistence.mapper.ts | 61 ++++ .../persistence/asset-relationship.model.ts | 77 +++++ .../persistence/load-workstations.ts | 51 ++++ .../persistence/sequelize-asset.repository.ts | 96 ++++++- .../workstation-persistence.mapper.ts | 101 +++++++ .../persistence/workstation.model.ts | 119 ++++++++ modules/assets/src/api/module.ts | 15 +- modules/assets/src/common/index.ts | 2 + modules/assets/src/common/inventory.ts | 9 +- modules/assets/src/common/relationships.ts | 25 ++ modules/assets/src/common/workstations.ts | 23 ++ modules/assets/src/web/asset-detail.tsx | 17 ++ modules/assets/src/web/asset-form.tsx | 44 +-- .../assets/src/web/asset-relationships.tsx | 268 ++++++++++++++++++ .../src/web/catalog-definition-dialog.tsx | 5 + modules/assets/src/web/catalog-help.tsx | 8 +- modules/assets/src/web/catalog-page.tsx | 33 ++- .../assets/src/web/catalog-relation-form.tsx | 97 +++++++ modules/assets/src/web/i18n.ts | 12 +- modules/assets/src/web/index.ts | 1 + modules/assets/src/web/inventory-api.ts | 17 ++ modules/assets/src/web/inventory-i18n.ts | 20 +- modules/assets/src/web/inventory-page.tsx | 15 +- modules/assets/src/web/inventory.test.tsx | 129 ++++++++- modules/assets/src/web/relationship-i18n.ts | 64 +++++ modules/assets/src/web/workstation-form.tsx | 254 +++++++++++++++++ modules/assets/src/web/workstation-i18n.ts | 52 ++++ modules/assets/src/web/workstations-page.tsx | 167 +++++++++++ .../src/web/components/company-detail.tsx | 14 +- .../src/web/components/company-inventory.tsx | 7 +- 64 files changed, 3117 insertions(+), 55 deletions(-) create mode 100644 apps/server/src/relationships.integration.ts create mode 100644 apps/server/src/workstations.integration.ts create mode 100644 docs/puestos-de-trabajo.md create mode 100644 docs/relaciones-entre-activos.md create mode 100644 modules/assets/src/api/application/asset-relationship-response.builder.ts create mode 100644 modules/assets/src/api/application/asset-relationship.use-cases.ts create mode 100644 modules/assets/src/api/application/workstation-input.mapper.ts create mode 100644 modules/assets/src/api/application/workstation-response.builder.ts create mode 100644 modules/assets/src/api/application/workstation.use-cases.ts create mode 100644 modules/assets/src/api/domain/asset-relationship.aggregate.ts create mode 100644 modules/assets/src/api/domain/asset-relationship.test.ts create mode 100644 modules/assets/src/api/domain/value-objects/asset-relationship-values.ts create mode 100644 modules/assets/src/api/domain/value-objects/workstation-values.ts create mode 100644 modules/assets/src/api/domain/workstation-revision.entity.ts create mode 100644 modules/assets/src/api/domain/workstation-values.ts create mode 100644 modules/assets/src/api/domain/workstation.aggregate.ts create mode 100644 modules/assets/src/api/domain/workstation.test.ts create mode 100644 modules/assets/src/api/infrastructure/http/asset-relationship.routes.ts create mode 100644 modules/assets/src/api/infrastructure/http/workstation.routes.ts create mode 100644 modules/assets/src/api/infrastructure/persistence/asset-relationship-persistence.mapper.ts create mode 100644 modules/assets/src/api/infrastructure/persistence/asset-relationship.model.ts create mode 100644 modules/assets/src/api/infrastructure/persistence/load-workstations.ts create mode 100644 modules/assets/src/api/infrastructure/persistence/workstation-persistence.mapper.ts create mode 100644 modules/assets/src/api/infrastructure/persistence/workstation.model.ts create mode 100644 modules/assets/src/common/relationships.ts create mode 100644 modules/assets/src/common/workstations.ts create mode 100644 modules/assets/src/web/asset-relationships.tsx create mode 100644 modules/assets/src/web/catalog-relation-form.tsx create mode 100644 modules/assets/src/web/relationship-i18n.ts create mode 100644 modules/assets/src/web/workstation-form.tsx create mode 100644 modules/assets/src/web/workstation-i18n.ts create mode 100644 modules/assets/src/web/workstations-page.tsx diff --git a/apps/server/src/assets.integration.ts b/apps/server/src/assets.integration.ts index 160b9f7..f73f18a 100644 --- a/apps/server/src/assets.integration.ts +++ b/apps/server/src/assets.integration.ts @@ -30,7 +30,7 @@ export async function verifyAssets( assert.equal(data.code, code); } const initial = await read(global); - assert.equal(initial.items.length, 42); + assert.equal(initial.items.length, 47); assert.equal(initial.items.filter((item) => item.values.kind === "template").length, 12); assert.ok(initial.items.every((item) => item.builtin && item.id.charAt(14) === "7")); assert.ok( diff --git a/apps/server/src/identity.test.ts b/apps/server/src/identity.test.ts index 822bb09..8c9c51f 100644 --- a/apps/server/src/identity.test.ts +++ b/apps/server/src/identity.test.ts @@ -23,6 +23,8 @@ import { verifyAssets } from "./assets.integration"; import { createDatabase, readDatabaseEnvironment } from "./config/database"; import { createIdentityRuntime } from "./infrastructure/http/identity-runtime"; import { verifyInventory } from "./inventory.integration"; +import { verifyRelationships } from "./relationships.integration"; +import { verifyWorkstations } from "./workstations.integration"; test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección del administrador", async () => { const environment = readDatabaseEnvironment(process.env); @@ -35,6 +37,10 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de await once(server, "listening"); await database.query("UPDATE identity_state SET initialAdminId = NULL WHERE id = 1"); await database.query("DELETE FROM http_sessions"); + 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 asset_relationships"); await database.query("DELETE FROM asset_revisions"); await database.query("DELETE FROM assets"); await database.query("DELETE FROM asset_availability"); @@ -693,6 +699,18 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de secondCompany.id, database ); + await verifyRelationships( + (...args) => operator.request(...args), + customer.id, + secondCompany.id, + database + ); + await verifyWorkstations( + (...args) => operator.request(...args), + customer.id, + secondCompany.id, + database + ); const countAfterAccess = await database.query<{ total: number }>( "SELECT COUNT(*) AS total FROM users", { type: QueryTypes.SELECT } @@ -702,6 +720,16 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de await expectError(await operator.request("/access/global"), 401, "UNAUTHENTICATED"); await expectError(await operator.request(`/assets/${customer.id}`), 401, "UNAUTHENTICATED"); assert.equal((await operator.session()).user, null); + await expectError( + await operator.request(`/assets/${customer.id}/relationships`, {}), + 401, + "UNAUTHENTICATED" + ); + await expectError( + await operator.request(`/assets/${customer.id}/workstations`, {}), + 401, + "UNAUTHENTICATED" + ); await expectError(await operator.request("/users"), 401, "UNAUTHENTICATED"); await expectError(await operator.request("/companies"), 401, "UNAUTHENTICATED"); await assert.rejects( diff --git a/apps/server/src/relationships.integration.ts b/apps/server/src/relationships.integration.ts new file mode 100644 index 0000000..e0ea447 --- /dev/null +++ b/apps/server/src/relationships.integration.ts @@ -0,0 +1,155 @@ +import assert from "node:assert/strict"; + +import { + AssetResponseSchema, + CatalogResponseSchema, + InventoryResponseSchema, + RelationshipResponseSchema, + SaveDefinitionResponseSchema, +} from "@metis/assets/common"; +import type { Sequelize } from "sequelize"; +import { z } from "zod/v4"; + +type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise; +export async function verifyRelationships( + request: Request, + companyId: string, + otherId: string, + database: Sequelize +) { + const path = `/assets/${companyId}`; + const catalog = CatalogResponseSchema.parse( + await (await request(`/asset-catalog/${companyId}`)).json() + ); + const type = catalog.items.find( + (item) => item.values.kind === "relation" && item.values.symmetric + ); + assert.ok(type); + const template = catalog.items.find( + (item) => item.values.kind === "template" && item.values.name === "Monitor" + ); + assert.ok(template); + const own = InventoryResponseSchema.parse(await (await request(path)).json()).assets; + const first = own[0]; + const second = own[1]; + assert.ok(first && second); + const foreignResponse = await request(`/assets/${otherId}`, { + values: { + name: "MON-Foreign", + templateId: template.id, + status: "available", + siteId: null, + personId: null, + characteristics: {}, + notes: "", + }, + }); + assert.equal(foreignResponse.status, 201); + const foreign = AssetResponseSchema.parse(await foreignResponse.json()).asset; + const input = { sourceId: first.id, targetId: second.id, typeId: type.id, notes: "USB" }; + const linkPath = `${path}/relationships`; + const save = async (values: unknown) => { + const response = await request(linkPath, { values }); + assert.equal(response.status, 201, await response.clone().text()); + return RelationshipResponseSchema.parse(await response.json()).relationship; + }; + const error = async (values: unknown, code: string, status = 409) => { + const response = await request(linkPath, { values }); + assert.equal(response.status, status); + assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code); + }; + await error({ ...input, targetId: first.id }, "SELF_RELATIONSHIP"); + await error({ ...input, targetId: foreign.id }, "ASSET_NOT_FOUND", 404); + await error({ ...input, typeId: template.id }, "RELATION_TYPE_UNAVAILABLE"); + await error({ ...input, createdBy: first.id }, "INVALID_REQUEST", 400); + assert.equal((await request(linkPath, { values: input }, "https://other.example")).status, 403); + assert.equal((await request(linkPath, { values: input }, undefined, "a".repeat(64))).status, 403); + const [created, retry] = await Promise.all([save(input), save(input)]); + assert.equal(created.id, retry.id); + assert.equal(created.id.charAt(14), "7"); + assert.ok(created.createdByName); + assert.equal((await save({ ...input, sourceId: second.id, targetId: first.id })).id, created.id); + await error({ ...input, notes: "Otro cable" }, "RELATIONSHIP_EXISTS"); + await assert.rejects( + database.query("UPDATE asset_relationships SET targetId=:target WHERE id=:id", { + replacements: { target: foreign.id, id: created.id }, + }) + ); + assert.equal( + (await request(`/asset-catalog/${companyId}/${type.id}/status`, { active: false })).status, + 200 + ); + await error(input, "RELATION_TYPE_UNAVAILABLE"); + const endPath = `${path}/relationships/${created.id}/end`; + const ending = await request(endPath, {}); + assert.equal(ending.status, 200); + const ended = RelationshipResponseSchema.parse(await ending.json()).relationship; + assert.ok(ended.endedAt); + assert.ok(ended.endedByName); + assert.deepEqual( + RelationshipResponseSchema.parse(await (await request(endPath, {})).json()).relationship, + ended + ); + assert.equal( + (await request(`/assets/${otherId}/relationships/${created.id}/end`, {})).status, + 404 + ); + assert.equal( + (await request(`/asset-catalog/${companyId}/${type.id}/status`, { active: null })).status, + 200 + ); + const replacement = await save(input); + assert.notEqual(replacement.id, created.id); + await verifyDirectional(request, companyId, otherId, first.id, second.id); + const stored = InventoryResponseSchema.parse(await (await request(path)).json()).relationships; + assert.deepEqual( + stored.find((item) => item.id === ended.id), + ended + ); + assert.ok( + !InventoryResponseSchema.parse(await (await request(`/assets/${otherId}`)).json()).relationships + .length + ); +} +async function verifyDirectional( + request: Request, + companyId: string, + otherId: string, + sourceId: string, + targetId: string +) { + const definition = { + kind: "relation", + name: "Replica a", + inverseName: "Recibe réplica de", + description: "", + symmetric: false, + }; + const response = await request(`/asset-catalog/${companyId}`, { values: definition }); + assert.equal(response.status, 201); + const typeId = SaveDefinitionResponseSchema.parse(await response.json()).savedId; + const path = `/assets/${companyId}/relationships`; + const values = { sourceId, targetId, typeId, notes: "" }; + const added = await request(path, { values }); + assert.equal(added.status, 201); + const link = RelationshipResponseSchema.parse(await added.json()).relationship; + const reverse = await request(path, { + values: { ...values, sourceId: targetId, targetId: sourceId }, + }); + assert.equal(reverse.status, 201); + assert.notEqual(RelationshipResponseSchema.parse(await reverse.json()).relationship.id, link.id); + const edited = await request(`/asset-catalog/${companyId}/${typeId}/update`, { + values: { ...definition, name: "Replica hacia" }, + }); + assert.equal(edited.status, 200); + const conversion = await request(`/asset-catalog/${companyId}/${typeId}/update`, { + values: { ...definition, symmetric: true }, + }); + assert.equal(conversion.status, 409); + const stored = InventoryResponseSchema.parse( + await (await request(`/assets/${companyId}`)).json() + ).relationships.find((item) => item.id === link.id); + assert.equal(stored?.values.name, "Replica a"); + const other = await request(`/assets/${otherId}/relationships`, { values }); + assert.equal(other.status, 404); +} diff --git a/apps/server/src/workstations.integration.ts b/apps/server/src/workstations.integration.ts new file mode 100644 index 0000000..3e7192b --- /dev/null +++ b/apps/server/src/workstations.integration.ts @@ -0,0 +1,136 @@ +import assert from "node:assert/strict"; + +import { InventoryResponseSchema, WorkstationResponseSchema } from "@metis/assets/common"; +import { CompanyResponseSchema } from "@metis/companies/common"; +import type { Sequelize } from "sequelize"; +import { z } from "zod/v4"; + +type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise; +export async function verifyWorkstations( + request: Request, + companyId: string, + otherId: string, + database: Sequelize +) { + const path = `/assets/${companyId}/workstations`; + const own = CompanyResponseSchema.parse( + await (await request(`/companies/${companyId}`)).json() + ).company; + const other = CompanyResponseSchema.parse( + await (await request(`/companies/${otherId}`)).json() + ).company; + const inventory = InventoryResponseSchema.parse( + await (await request(`/assets/${companyId}`)).json() + ); + const foreign = InventoryResponseSchema.parse(await (await request(`/assets/${otherId}`)).json()) + .assets[0]; + const first = inventory.assets[0]; + const second = inventory.assets[1]; + assert.ok(first && second && foreign); + const personFields = { + name: "Persona puesto", + position: null, + email: null, + phone: null, + active: true, + isContact: false, + }; + const personResponse = await request(`/companies/${companyId}/people`, personFields); + assert.equal(personResponse.status, 201); + const person = CompanyResponseSchema.parse(await personResponse.json()).company.people.at(-1); + assert.ok(person); + const input = { + name: "Puesto Ana", + active: true, + siteId: own.sites[0]?.id ?? null, + personId: person.id, + assetIds: [first.id, second.id], + }; + const save = async (values: unknown, id?: string) => { + const response = await request(id ? `${path}/${id}/update` : path, { values }); + assert.equal(response.status, id ? 200 : 201, await response.clone().text()); + return WorkstationResponseSchema.parse(await response.json()).workstation; + }; + const error = async (values: unknown, code: string, id?: string, status = 409) => { + const response = await request(id ? `${path}/${id}/update` : path, { values }); + assert.equal(response.status, status); + assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code); + }; + await error({ ...input, assetIds: [foreign.id] }, "ASSET_NOT_FOUND", undefined, 404); + await error({ ...input, siteId: other.sites[0]?.id }, "SITE_NOT_FOUND", undefined, 404); + await error({ ...input, personId: other.people[0]?.id }, "PERSON_UNAVAILABLE"); + await error({ ...input, assetIds: [first.id, first.id] }, "INVALID_MEMBERS"); + await error({ ...input, actorId: first.id }, "INVALID_REQUEST", undefined, 400); + assert.equal((await request(path, { values: input }, "https://other.example")).status, 403); + assert.equal((await request(path, { values: input }, undefined, "a".repeat(64))).status, 403); + const [a, b] = await Promise.all([ + request(path, { values: input }), + request(path, { values: { ...input, name: "Puesto conflictivo" } }), + ]); + assert.deepEqual([a.status, b.status].sort(), [201, 409]); + const success = a.status === 201 ? a : b; + const created = WorkstationResponseSchema.parse(await success.json()).workstation; + assert.equal(created.id.charAt(14), "7"); + assert.equal(created.history[0]?.id.charAt(14), "7"); + assert.ok(created.history[0]?.actorName); + const unchanged = await save( + { ...created.values, assetIds: [...created.values.assetIds].reverse() }, + created.id + ); + assert.deepEqual(unchanged, created); + const empty = await save({ ...input, name: "Puesto vacío", siteId: null, assetIds: [] }); + assert.equal( + ( + await request(`/companies/${companyId}/people/${person.id}/update`, { + ...personFields, + active: false, + }) + ).status, + 200 + ); + await error({ ...empty.values, name: "Nueva asignación inactiva" }, "PERSON_UNAVAILABLE"); + assert.deepEqual(await save(created.values, created.id), created); + await error({ ...empty.values, assetIds: [first.id] }, "ASSET_IN_WORKSTATION", empty.id); + await error({ ...empty.values, assetIds: [created.id] }, "ASSET_NOT_FOUND", empty.id, 404); + const inactive = await save({ ...created.values, active: false }, created.id); + assert.equal(inactive.history.length, 2); + const receiver = await save({ ...empty.values, assetIds: [first.id] }, empty.id); + await error({ ...created.values, active: true }, "ASSET_IN_WORKSTATION", created.id); + const removed = await save({ ...receiver.values, assetIds: [] }, receiver.id); + assert.equal(removed.history.length, 3); + const restored = await save(created.values, created.id); + assert.equal(restored.history.length, 3); + assert.deepEqual(restored.history[1]?.values.assetIds, created.values.assetIds); + const foreignUpdate = await request(`/assets/${otherId}/workstations/${created.id}/update`, { + values: created.values, + }); + assert.equal(foreignUpdate.status, 404); + await assert.rejects( + database.query("UPDATE workstation_assets SET assetId=:asset WHERE workstationId=:id", { + replacements: { asset: foreign.id, id: created.id }, + }) + ); + const corruption = { ...restored.values, assetIds: [foreign.id] }; + await database.query("UPDATE workstations SET `values`=:values WHERE id=:id", { + replacements: { values: JSON.stringify(corruption), id: restored.id }, + }); + try { + assert.equal((await request(`/assets/${companyId}`)).status, 500); + } finally { + await database.query("UPDATE workstations SET `values`=:values WHERE id=:id", { + replacements: { values: JSON.stringify(restored.values), id: restored.id }, + }); + } + const current = InventoryResponseSchema.parse( + await (await request(`/assets/${companyId}`)).json() + ); + assert.deepEqual( + current.workstations.find((item) => item.id === restored.id), + restored + ); + assert.equal( + InventoryResponseSchema.parse(await (await request(`/assets/${otherId}`)).json()).workstations + .length, + 0 + ); +} diff --git a/apps/web/src/app.test.tsx b/apps/web/src/app.test.tsx index 2716d76..62e1df3 100644 --- a/apps/web/src/app.test.tsx +++ b/apps/web/src/app.test.tsx @@ -102,6 +102,8 @@ test("el espacio mantiene compañía, secciones y ficha del primer activo sin fo client.setQueryData(companyKeys.detail(workspaceCompany.id), { company: workspaceCompany }); client.setQueryData(inventoryKeys.company(workspaceCompany.id), { assets: workspaceAssets, + relationships: [], + workstations: [], }); client.setQueryData(catalogKeys.scope(workspaceCompany.id), workspaceCatalog); } diff --git a/docs/arquitectura/modelo-ddd-e-identificadores.md b/docs/arquitectura/modelo-ddd-e-identificadores.md index ab1b114..c9e481b 100644 --- a/docs/arquitectura/modelo-ddd-e-identificadores.md +++ b/docs/arquitectura/modelo-ddd-e-identificadores.md @@ -49,6 +49,10 @@ AssetCatalog es la raíz del catálogo de activos; AssetDefinition, TemplateFiel 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). + +Workstation es la raíz de cada puesto y WorkstationRevision una entidad con UUID propio para cada instantánea. WorkstationValues encapsula el nombre, estado, referencias opcionales y composición. Las referencias técnicas de reserva se derivan del agregado. Consultar [Puestos de trabajo](../puestos-de-trabajo.md). + Los módulos de documentos y tickets todavía no existen. Se declararán como agregados o entidades al implementar sus operaciones y reglas de consistencia. La decisión de pertenencia a un agregado se toma por sus invariantes y transacciones; no convierte automáticamente cada tabla en una raíz ni fuerza todo el cliente y sus tickets dentro de un solo agregado. Cada entidad que se incorpore deberá tener UniqueID desde su primera implementación, mapper de persistencia y pruebas de identidad, invariantes, rehidratación y actualización. diff --git a/docs/campos-y-plantillas-de-activos.md b/docs/campos-y-plantillas-de-activos.md index 68b1eb9..b23733b 100644 --- a/docs/campos-y-plantillas-de-activos.md +++ b/docs/campos-y-plantillas-de-activos.md @@ -3,7 +3,7 @@ ## Uso -El Catálogo de activos de la navegación principal contiene las definiciones globales. La ficha de cada compañía incorpora Plantillas de activos: combina las globales con sus adiciones propias. +El Catálogo de activos de la navegación principal contiene las definiciones globales. La ficha de cada compañía incorpora Catálogo de activos: combina las globales con sus adiciones propias. Las altas y ediciones se abren mediante el botón de cabecera o la acción del listado. Los formularios son modales, con ayuda inferior y Guardar / Cancelar. Ver consulta una definición sin modificarla. Crear copia inicia una nueva plantilla personalizable. @@ -15,7 +15,7 @@ El selector de campos reutiliza SelectWithAction: el botón «+» abre el alta d ## Datos de serie -Se incluyen 30 campos y 12 plantillas: portátil, PC de sobremesa, monitor, impresora, router, firewall, switch, punto de acceso Wi-Fi, NAS, servidor, smartphone y SAI. +Se incluyen además cinco [tipos de relación](relaciones-entre-activos.md). Hay 30 campos y 12 plantillas: portátil, PC de sobremesa, monitor, impresora, router, firewall, switch, punto de acceso Wi-Fi, NAS, servidor, smartphone y SAI. Los campos cubren marca, modelo, número de serie, compra y garantía; procesador, RAM, almacenamiento y sistema operativo; pantalla, resolución y conexiones; IP, MAC, puertos, velocidad, PoE y Wi-Fi; discos, capacidad y RAID; impresión; telefonía; potencia y autonomía. @@ -41,6 +41,6 @@ Las tablas asset_catalogs, asset_definitions, asset_template_fields y asset_avai Las rutas GET /api/asset-catalog/:scope, POST /api/asset-catalog/:scope, POST /api/asset-catalog/:scope/:id/update y POST /api/asset-catalog/:scope/:id/status requieren sesión. Las escrituras usan la protección de origen y CSRF existente. scope es global o el UUID de una compañía existente. La respuesta solo expone el catálogo visible de ese ámbito. -## Siguiente entrega +## Inventario y relaciones -Registrar activos reales utilizando estas plantillas, con compañía, sede, asignación a personas y movimientos. Las relaciones y conjuntos de puesto de trabajo se incorporarán después. Este bloque no registra todavía inventario, configuración por dispositivo ni relaciones entre activos. +El [inventario de activos](inventario-de-activos.md) utiliza estas plantillas. El mismo catálogo incorpora los [tipos de relación](relaciones-entre-activos.md). Los [puestos de trabajo](puestos-de-trabajo.md) agrupan los activos registrados. diff --git a/docs/inventario-de-activos.md b/docs/inventario-de-activos.md index e44af6e..e0cda43 100644 --- a/docs/inventario-de-activos.md +++ b/docs/inventario-de-activos.md @@ -42,4 +42,4 @@ GET /api/assets/:companyId consulta el inventario. POST /api/assets/:companyId y Las pruebas verifican características opcionales y obligatorias, tipos, fechas, cero y falso, UUID, igualdad semántica, aislamiento por compañía, historial, desactivaciones, rehidratación y protección de las escrituras. La comprobación en navegador incluye alta rápida, cancelación, conservación de fechas y consulta del historial. -Las relaciones entre activos y los conjuntos de puesto de trabajo se incorporarán en el siguiente bloque. Configuración por dispositivo, teletrabajo, inventario de dispositivos personales y procesos de salida mantienen su orden en el plan. +Las [relaciones entre activos](relaciones-entre-activos.md) están disponibles en ambas fichas. Los [puestos de trabajo](puestos-de-trabajo.md) están disponibles como agrupaciones sin anidación. Configuración por dispositivo, teletrabajo, inventario de dispositivos personales y procesos de salida mantienen su orden en el plan. diff --git a/docs/plan-de-desarrollo.md b/docs/plan-de-desarrollo.md index c81e1c2..7e7ab82 100644 --- a/docs/plan-de-desarrollo.md +++ b/docs/plan-de-desarrollo.md @@ -41,10 +41,9 @@ Catálogo global con 30 campos y 12 plantillas de serie protegidas, cinco tipos ## Siguientes entregas: orden actualizado -1. Relaciones entre activos y conjuntos de puesto de trabajo. -2. Perfil de configuración por dispositivo: correos con POP/IMAP, carpetas compartidas, impresoras y recursos. -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. +1. Perfil de configuración por dispositivo: correos con POP/IMAP, carpetas compartidas, impresoras y recursos. +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. Este orden se concreta antes de cada entrega. Las licencias y su relación con cuentas se explorarán posteriormente. @@ -52,7 +51,7 @@ Este orden se concreta antes de cada entrega. Las licencias y su relación con c Activos por compañía, plantilla capturada, sede y persona opcionales, cuatro estados, características tipadas y revisiones con actor y fecha. Altas rápidas y consulta del historial mediante modales. Consultar [Inventario de activos](inventario-de-activos.md). -Los conjuntos de puesto de trabajo y las relaciones entre activos siguen pendientes. +Las [relaciones entre activos](relaciones-entre-activos.md) están implementadas: catálogo global y local, cinco tipos de serie, vínculos en ambos sentidos y retirada con auditoría. Los [puestos de trabajo](puestos-de-trabajo.md) están implementados, sin anidación, con sede y persona opcionales, reserva exclusiva de activos en puestos vigentes e historial. ## Tickets e intervenciones: pendiente @@ -66,4 +65,4 @@ Las credenciales se resolverán con una solución específica; no almacenarlas c ## Decisiones pendientes -Permisos por técnico si llegan a necesitarse, despliegue, backups, recuperación administrativa y modelo de organización. El acceso y los usuarios ya están implementados; la base de compañías clientes, personas, departamentos, asignaciones y sedes está implementada. El inventario está implementado; las relaciones entre activos y los tickets siguen pendientes. +Permisos por técnico si llegan a necesitarse, despliegue, backups, recuperación administrativa y modelo de organización. El acceso y los usuarios ya están implementados; la base de compañías clientes, personas, departamentos, asignaciones y sedes está implementada. El inventario está implementado; los tickets siguen pendientes. diff --git a/docs/puestos-de-trabajo.md b/docs/puestos-de-trabajo.md new file mode 100644 index 0000000..113c35e --- /dev/null +++ b/docs/puestos-de-trabajo.md @@ -0,0 +1,33 @@ +# Puestos de trabajo + +## Uso + +La sección Puestos de trabajo de cada compañía agrupa los activos utilizados habitualmente como un conjunto. Un puesto tiene UUID v7, nombre, estado activo/inactivo, sede opcional, persona opcional y hasta 200 activos. Se puede crear vacío y completar posteriormente. Una persona puede tener varios puestos. Los puestos no contienen otros puestos. + +Nuevo puesto abre un modal desde la cabecera derecha; Editar abre otro modal con los datos existentes. El formulario ofrece selectores con «+» para dar de alta sedes, personas y activos sin perder sus datos. Guardar un elemento relacionado lo selecciona o incorpora al puesto; cancelarlo mantiene el formulario principal. Los elementos guardados sobreviven si posteriormente se cancela el puesto. El alta de activo desde un puesto usa plantillas, sedes y personas existentes para evitar un tercer modal. + +La ayuda aparece inmediatamente antes de Guardar y Cancelar. El listado permite buscar por nombre, persona o sede. Historial del puesto muestra las versiones completas, con estado, asignación y composición, fecha y responsable. La ficha de cada activo muestra su puesto vigente. + +## Reglas + +Un activo solo puede pertenecer a un puesto activo. El formulario marca como ocupados los activos de otros puestos y no permite seleccionarlos para un puesto activo. El servidor vuelve a comprobarlo dentro de una transacción y la base de datos protege la reserva con una clave única. Dos altas concurrentes no pueden ocupar el mismo activo. + +Desactivar un puesto conserva su composición y libera sus reservas. Un activo puede figurar en composiciones de puestos inactivos. Reactivar comprueba de nuevo la disponibilidad: si un activo está ocupado, hay que retirarlo de uno de los puestos antes de guardar. Retirar un activo del puesto no elimina el activo ni sus relaciones. + +Persona, sede y activos deben pertenecer a la misma compañía. Una persona debe estar activa para una nueva asignación. Si se desactiva después, se conserva la asignación anterior para revisión y puede retirarse. La persona no recibe acceso a Metis Desk. + +La asignación del puesto no cambia automáticamente sede, persona o estado de cada activo; son registros independientes. Tampoco se crean conexiones entre equipos automáticamente: se registran mediante relaciones entre activos. Los activos retirados pueden permanecer en una composición para revisión. + +No se eliminan puestos ni revisiones. Guardar valores normalizados equivalentes no añade una revisión; cambiar solo el orden de selección de activos tampoco. Cambiar nombre, estado, sede, persona o composición registra una instantánea nueva con UUID propio y conserva el UUID del puesto. + +## Arquitectura y API + +Workstation es un agregado; WorkstationRevision es una entidad interna. WorkstationValues es un Value Object inmutable con nombres Name, referencias UniqueID y opcionales Maybe. Infrastructure reconstruye y valida los valores, la última revisión, la auditoría y el ámbito. + +Las tablas workstations y workstation_revisions mantienen raíces y revisiones con claves foráneas a compañía, sede, persona y actor. workstation_assets es una proyección técnica de las referencias utilizadas en todo el historial y de sus reservas vigentes; no representa otra entidad de negocio. Conserva claves foráneas a activos históricos, claves compuestas para la pertenencia a compañía y una clave única nullable para la reserva. Las migraciones son idempotentes. + +El repositorio del inventario carga los puestos y serializa sus escrituras con el bloqueo de catálogo y compañía ya utilizado por activos y relaciones. Conserva revisiones existentes y solo inserta las nuevas. + +GET /api/assets/:companyId incluye workstations junto a assets y relationships. POST /api/assets/:companyId/workstations y POST /api/assets/:companyId/workstations/:id/update reciben values. Las escrituras requieren sesión, origen válido y CSRF; el actor procede de la sesión y no se admite en el cuerpo. + +Las pruebas verifican identidad, composición opcional, límites, igualdad, historial, ámbito, ausencia de anidación, desactivación y reactivación, personas inactivas, concurrencia y persistencia. La comprobación de navegador incluye altas rápidas, cancelación, composición, activos ocupados e historial. diff --git a/docs/relaciones-entre-activos.md b/docs/relaciones-entre-activos.md new file mode 100644 index 0000000..d352f2e --- /dev/null +++ b/docs/relaciones-entre-activos.md @@ -0,0 +1,29 @@ +# Relaciones entre activos + +## Uso y catálogo + +Catálogo de activos incorpora la pestaña Relaciones, tanto global como por compañía. Las definiciones de serie son Conectado a, Alojado en / Aloja, Depende de / Es necesario para, Alimentado por / Alimenta e Instalado en / Tiene instalado. Se pueden añadir tipos globales o exclusivos de una compañía. Las definiciones de serie se pueden activar o desactivar, pero no modificar ni borrar. + +La disponibilidad global actúa como valor predeterminado; una compañía puede personalizarla y volver a usar la configuración global. Desactivar un tipo impide crear vínculos nuevos, pero conserva los existentes y permite retirarlos. Una definición con dirección tiene un nombre por sentido; una simétrica usa el mismo nombre en ambos. Esta elección queda fija tras el alta. + +En la ficha lateral o Ficha e historial, Relacionar activo abre un modal para seleccionar el tipo, otro activo y notas opcionales. El «+» junto al tipo permite crearlo para esa compañía y lo selecciona sin perder el resto del formulario. La nueva definición se conserva aunque después se cancele el vínculo. La ayuda queda inmediatamente antes de Guardar y Cancelar. + +Las fichas de ambos extremos muestran el vínculo con su nombre correspondiente. Por ejemplo, la máquina virtual muestra Alojado en · Servidor y el servidor muestra Aloja · Máquina virtual. Los nombres se capturan al crear cada vínculo; renombrar un tipo propio no reescribe los vínculos anteriores. + +## Reglas e historial + +Ambos activos pertenecen a la misma compañía y deben existir. Un activo no puede relacionarse consigo mismo. Una relación vigente no puede duplicarse con el mismo tipo y extremos: en las simétricas, invertir los extremos tampoco crea otro vínculo. Repetir exactamente el alta devuelve la relación existente; cambiar las notas de un duplicado produce un error. En las relaciones con dirección, el sentido inverso es un vínculo distinto. + +Retirar relación requiere una confirmación en un modal. Conserva UUID, extremos, notas, tipo y autor y fecha de creación, añadiendo autor y fecha de retirada. Repetir la retirada no altera los datos. Las relaciones retiradas se consultan en un apartado desplegable. Se puede volver a crear un vínculo retirado, con un nuevo UUID. + +Los activos retirados conservan sus relaciones para revisión; retirar el activo no desconecta automáticamente otros equipos. No se incluyen restricciones por plantilla, detección de ciclos ni conjuntos anidados. Los [puestos de trabajo](puestos-de-trabajo.md) agrupan activos sin crear relaciones automáticamente. + +## Arquitectura y API + +AssetRelationship es un agregado con UniqueID UUID v7. AssetRelationshipValues es un Value Object inmutable que reconstruye los extremos y el tipo como UniqueID, y los nombres como Name. AssetCatalog conserva los tipos como AssetDefinition de clase relation. + +Infrastructure reconstruye y valida valores, auditoría, pertenencia y referencias. asset_relationships contiene claves foráneas a compañía, activos, tipo y usuarios. Las claves compuestas impiden extremos de otra compañía; la clave vigente única evita duplicados incluso ante concurrencia. Las escrituras transaccionales bloquean catálogo y compañía en el orden compartido con el inventario. + +GET /api/assets/:companyId devuelve assets y relationships, incluidas las retiradas. POST /api/assets/:companyId/relationships recibe values con sourceId, targetId, typeId y notes. POST /api/assets/:companyId/relationships/:id/end recibe un cuerpo vacío. Ambas escrituras requieren sesión, origen válido y CSRF; el actor procede de la sesión. + +Las pruebas cubren UUID, dirección, simetría, autovínculos, duplicados concurrentes, ámbito, disponibilidad, protección de serie, dirección fija, instantáneas, auditoría e idempotencia. La comprobación en navegador verifica alta, alta rápida, cancelación y retirada. diff --git a/modules/assets/package.json b/modules/assets/package.json index 39e08c1..9cdf84f 100644 --- a/modules/assets/package.json +++ b/modules/assets/package.json @@ -10,7 +10,7 @@ }, "scripts": { "typecheck": "tsc -p tsconfig.json", - "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/web/catalog.test.tsx src/web/inventory.test.tsx" + "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/api/domain/workstation.test.ts src/api/domain/asset-relationship.test.ts src/web/catalog.test.tsx src/web/inventory.test.tsx" }, "dependencies": { "@repo/rdx-ddd": "workspace:*", diff --git a/modules/assets/src/api/application/asset-relationship-response.builder.ts b/modules/assets/src/api/application/asset-relationship-response.builder.ts new file mode 100644 index 0000000..c830d7d --- /dev/null +++ b/modules/assets/src/api/application/asset-relationship-response.builder.ts @@ -0,0 +1,19 @@ +import type { RelationshipDTO } from "../../common/relationships"; +import type { AssetRelationship } from "../domain/asset-relationship.aggregate"; +export function buildRelationshipResponse( + item: AssetRelationship, + actors: ReadonlyMap +): RelationshipDTO { + const endedBy = item.endedBy.getOrUndefined()?.toPrimitive() ?? null; + return { + id: item.id.toPrimitive(), + companyId: item.companyId.toPrimitive(), + values: item.values.toPrimitive(), + createdBy: item.createdBy.toPrimitive(), + createdByName: actors.get(item.createdBy.toPrimitive()) ?? "", + createdAt: item.createdAt, + endedBy, + endedByName: endedBy ? (actors.get(endedBy) ?? "") : null, + endedAt: item.endedAt.getOrUndefined() ?? null, + }; +} diff --git a/modules/assets/src/api/application/asset-relationship.use-cases.ts b/modules/assets/src/api/application/asset-relationship.use-cases.ts new file mode 100644 index 0000000..959e28d --- /dev/null +++ b/modules/assets/src/api/application/asset-relationship.use-cases.ts @@ -0,0 +1,80 @@ +import { UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import type { RelationshipInputDTO } from "../../common/relationships"; +import { AssetCatalogError } from "../domain/asset-definition"; +import { AssetRelationship } from "../domain/asset-relationship.aggregate"; +import { AssetRelationshipValues } from "../domain/value-objects/asset-relationship-values"; + +import type { IAssetRepository, InventoryContext } from "./asset.repository"; + +export class AssetRelationshipUseCases { + constructor(private readonly repository: IAssetRepository) {} + save(companyId: string, input: RelationshipInputDTO, actorId: string) { + const company = UniqueID.create(companyId); + const actor = UniqueID.create(actorId); + if (!(company.isSuccess && actor.isSuccess)) { + return Promise.resolve(Result.fail(new AssetCatalogError("INVALID_REQUEST"))); + } + return this.repository.saveRelationship(company.data, (context) => { + const mapped = mapRelationshipInput(input, company.data, context); + if (!mapped.isSuccess) { + return Result.fail(mapped.error); + } + const previous = context.relationships.find( + (item) => item.active && item.values.key() === mapped.data.key() + ); + if (previous) { + return previous.values.notes === mapped.data.notes + ? Result.ok(previous) + : Result.fail(new AssetCatalogError("RELATIONSHIP_EXISTS")); + } + return AssetRelationship.create( + company.data, + mapped.data, + actor.data, + new Date().toISOString() + ); + }); + } + end(companyId: string, id: string, actorId: string) { + const company = UniqueID.create(companyId); + const identity = UniqueID.create(id); + const actor = UniqueID.create(actorId); + if (!(company.isSuccess && identity.isSuccess && actor.isSuccess)) { + return Promise.resolve(Result.fail(new AssetCatalogError("INVALID_REQUEST"))); + } + return this.repository.saveRelationship(company.data, (context) => { + const item = context.relationships.find((entry) => entry.id.equals(identity.data)); + return item + ? item.end(actor.data, new Date().toISOString()) + : Result.fail(new AssetCatalogError("RELATIONSHIP_NOT_FOUND")); + }); + } +} +export function mapRelationshipInput( + input: RelationshipInputDTO, + company: UniqueID, + context: InventoryContext +) { + if ( + ![input.sourceId, input.targetId].every((id) => + context.assets.some((asset) => asset.id.toPrimitive() === id) + ) + ) { + return Result.fail(new AssetCatalogError("ASSET_NOT_FOUND")); + } + const scope = Maybe.some(company); + const type = context.catalog + .visible(scope) + .find((item) => item.id.toPrimitive() === input.typeId); + if (!(type && type.values.data.kind === "relation" && context.catalog.active(type, scope))) { + return Result.fail(new AssetCatalogError("RELATION_TYPE_UNAVAILABLE")); + } + return AssetRelationshipValues.create({ + ...input, + name: type.values.data.name, + inverseName: type.values.data.inverseName, + symmetric: type.values.data.symmetric, + }); +} diff --git a/modules/assets/src/api/application/asset.repository.ts b/modules/assets/src/api/application/asset.repository.ts index fd5a990..a67f810 100644 --- a/modules/assets/src/api/application/asset.repository.ts +++ b/modules/assets/src/api/application/asset.repository.ts @@ -4,17 +4,36 @@ import { Result } from "@repo/rdx-utils"; import type { Asset } from "../domain/asset.aggregate"; import type { AssetCatalog } from "../domain/asset-catalog.aggregate"; import { AssetCatalogError } from "../domain/asset-definition"; +import type { AssetRelationship } from "../domain/asset-relationship.aggregate"; import type { AssetValues } from "../domain/value-objects/asset-values"; +import type { Workstation } from "../domain/workstation.aggregate"; export type InventoryContext = Readonly<{ + workstations: readonly Workstation[]; catalog: AssetCatalog; assets: readonly Asset[]; + relationships: readonly AssetRelationship[]; sites: readonly UniqueID[]; people: readonly { id: UniqueID; active: boolean }[]; actorNames: ReadonlyMap; }>; export interface IAssetRepository { + saveWorkstation( + companyId: UniqueID, + action: (context: InventoryContext) => Result + ): Promise< + Result<{ workstation: Workstation; actorNames: ReadonlyMap }, AssetCatalogError> + >; get(companyId: UniqueID): Promise>; + saveRelationship( + companyId: UniqueID, + action: (context: InventoryContext) => Result + ): Promise< + Result< + { relationship: AssetRelationship; actorNames: ReadonlyMap }, + AssetCatalogError + > + >; save( companyId: UniqueID, action: (context: InventoryContext) => Result diff --git a/modules/assets/src/api/application/workstation-input.mapper.ts b/modules/assets/src/api/application/workstation-input.mapper.ts new file mode 100644 index 0000000..c89c08d --- /dev/null +++ b/modules/assets/src/api/application/workstation-input.mapper.ts @@ -0,0 +1,47 @@ +import { Result } from "@repo/rdx-utils"; + +import { AssetCatalogError } from "../domain/asset-definition"; +import { WorkstationValues } from "../domain/value-objects/workstation-values"; +import type { Workstation } from "../domain/workstation.aggregate"; +import type { WorkstationInput } from "../domain/workstation-values"; + +import type { InventoryContext } from "./asset.repository"; +export function mapWorkstationInput( + input: WorkstationInput, + context: InventoryContext, + previous?: Workstation +) { + const mapped = WorkstationValues.create(input); + if (!mapped.isSuccess) { + return mapped; + } + const values = mapped.data.toPrimitive(); + if (values.siteId && !context.sites.some((id) => id.toPrimitive() === values.siteId)) { + return Result.fail(new AssetCatalogError("SITE_NOT_FOUND")); + } + const person = context.people.find((item) => item.id.toPrimitive() === values.personId); + if ( + values.personId && + (!person || (!person.active && previous?.values.toPrimitive().personId !== values.personId)) + ) { + return Result.fail(new AssetCatalogError("PERSON_UNAVAILABLE")); + } + for (const id of values.assetIds) { + const asset = context.assets.find((item) => item.id.toPrimitive() === id); + if (!asset) { + return Result.fail(new AssetCatalogError("ASSET_NOT_FOUND")); + } + if ( + values.active && + context.workstations.some( + (item) => + item.id.toPrimitive() !== previous?.id.toPrimitive() && + item.values.active && + item.values.assetIds.some((assetId) => assetId.toPrimitive() === id) + ) + ) { + return Result.fail(new AssetCatalogError("ASSET_IN_WORKSTATION")); + } + } + return mapped; +} diff --git a/modules/assets/src/api/application/workstation-response.builder.ts b/modules/assets/src/api/application/workstation-response.builder.ts new file mode 100644 index 0000000..d9d2224 --- /dev/null +++ b/modules/assets/src/api/application/workstation-response.builder.ts @@ -0,0 +1,19 @@ +import type { WorkstationDTO } from "../../common/workstations"; +import type { Workstation } from "../domain/workstation.aggregate"; +export function buildWorkstationResponse( + item: Workstation, + names: ReadonlyMap +): WorkstationDTO { + return { + id: item.id.toPrimitive(), + companyId: item.companyId.toPrimitive(), + values: item.values.toPrimitive(), + history: item.history.map((revision) => ({ + id: revision.id.toPrimitive(), + values: revision.values.toPrimitive(), + actorId: revision.actorId.toPrimitive(), + actorName: names.get(revision.actorId.toPrimitive()) ?? "", + recordedAt: revision.recordedAt, + })), + }; +} diff --git a/modules/assets/src/api/application/workstation.use-cases.ts b/modules/assets/src/api/application/workstation.use-cases.ts new file mode 100644 index 0000000..2d5349f --- /dev/null +++ b/modules/assets/src/api/application/workstation.use-cases.ts @@ -0,0 +1,36 @@ +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { AssetCatalogError } from "../domain/asset-definition"; +import { Workstation } from "../domain/workstation.aggregate"; +import type { WorkstationInput } from "../domain/workstation-values"; + +import type { IAssetRepository } from "./asset.repository"; +import { mapWorkstationInput } from "./workstation-input.mapper"; +export class WorkstationUseCases { + constructor(private readonly repository: IAssetRepository) {} + save(companyId: string, id: string | undefined, input: WorkstationInput, 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 AssetCatalogError("INVALID_REQUEST"))); + } + return this.repository.saveWorkstation(company.data, (context) => { + const previous = identity?.isSuccess + ? context.workstations.find((item) => item.id.equals(identity.data)) + : undefined; + if (id && !previous) { + return Result.fail(new AssetCatalogError("WORKSTATION_NOT_FOUND")); + } + const values = mapWorkstationInput(input, context, previous); + if (!values.isSuccess) { + return Result.fail(values.error); + } + const now = new Date().toISOString(); + return previous + ? previous.update(values.data, actor.data, now) + : Workstation.create(company.data, values.data, actor.data, now); + }); + } +} diff --git a/modules/assets/src/api/domain/asset-catalog-seed.ts b/modules/assets/src/api/domain/asset-catalog-seed.ts index 51e8435..7c60ab4 100644 --- a/modules/assets/src/api/domain/asset-catalog-seed.ts +++ b/modules/assets/src/api/domain/asset-catalog-seed.ts @@ -100,5 +100,14 @@ export function seedAssetCatalog(catalog: AssetCatalog) { }); add(`template:${key}`, { kind: "template", name, description: "", fields: references }); } + for (const [key, name, inverseName, symmetric] of [ + ["connected", "Conectado a", "Conectado a", true], + ["hosted", "Alojado en", "Aloja", false], + ["depends", "Depende de", "Es necesario para", false], + ["powered", "Alimentado por", "Alimenta", false], + ["installed", "Instalado en", "Tiene instalado", false], + ] as const) { + add(`relation:${key}`, { kind: "relation", name, inverseName, symmetric, description: "" }); + } return current; } diff --git a/modules/assets/src/api/domain/asset-catalog.test.ts b/modules/assets/src/api/domain/asset-catalog.test.ts index 5423449..5c12d40 100644 --- a/modules/assets/src/api/domain/asset-catalog.test.ts +++ b/modules/assets/src/api/domain/asset-catalog.test.ts @@ -24,7 +24,7 @@ function changed(result: ReturnType) { } test("el catálogo inicial es idempotente, con UUID propios y características opcionales", () => { const catalog = seedAssetCatalog(AssetCatalog.create()); - assert.equal(catalog.definitions.length, 42); + assert.equal(catalog.definitions.length, 47); assert.equal(seedAssetCatalog(catalog), catalog); const ids = [ catalog.id, @@ -58,7 +58,7 @@ test("la disponibilidad por compañía conserva el global y los campos desactiva assert.equal(catalog.active(stock, first), false); assert.equal(catalog.active(stock, second), true); assert.equal(stock.active, true); - assert.equal(catalog.definitions.length, 42); + assert.equal(catalog.definitions.length, 47); const overrideId = catalog.availability[0]?.id; assert.ok(overrideId); assert.equal(changed(catalog.setActive(first, stock.id, false)), catalog); diff --git a/modules/assets/src/api/domain/asset-definition.entity.ts b/modules/assets/src/api/domain/asset-definition.entity.ts index 34ed6a0..167fbf2 100644 --- a/modules/assets/src/api/domain/asset-definition.entity.ts +++ b/modules/assets/src/api/domain/asset-definition.entity.ts @@ -76,6 +76,13 @@ export class AssetDefinition extends DomainEntity { ) { return Result.fail(new AssetCatalogError("FIELD_TYPE_FIXED")); } + if ( + previous.kind === "relation" && + next.kind === "relation" && + previous.symmetric !== next.symmetric + ) { + return Result.fail(new AssetCatalogError("RELATION_DIRECTION_FIXED")); + } if (this.values.equals(values)) { return Result.ok(this); } diff --git a/modules/assets/src/api/domain/asset-definition.ts b/modules/assets/src/api/domain/asset-definition.ts index ee40a1b..e9c33a0 100644 --- a/modules/assets/src/api/domain/asset-definition.ts +++ b/modules/assets/src/api/domain/asset-definition.ts @@ -18,6 +18,13 @@ export const AssetDefinitionSchema = z.discriminatedUnion("kind", [ description: z.string().max(1000), fields: z.array(TemplateFieldSchema).min(1).max(100), }), + z.strictObject({ + kind: z.literal("relation"), + name: title, + inverseName: title, + description: z.string().max(1000), + symmetric: z.boolean(), + }), ]); export type AssetDefinitionSpec = z.infer; export type AssetFieldType = z.infer; diff --git a/modules/assets/src/api/domain/asset-relationship.aggregate.ts b/modules/assets/src/api/domain/asset-relationship.aggregate.ts new file mode 100644 index 0000000..f0a7be2 --- /dev/null +++ b/modules/assets/src/api/domain/asset-relationship.aggregate.ts @@ -0,0 +1,78 @@ +import { AggregateRoot, type UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; +import { z } from "zod/v4"; + +import { AssetCatalogError } from "./asset-definition"; +import type { AssetRelationshipValues } from "./value-objects/asset-relationship-values"; + +type RelationshipProps = Readonly<{ + companyId: UniqueID; + values: AssetRelationshipValues; + createdBy: UniqueID; + createdAt: string; + endedBy: Maybe; + endedAt: Maybe; +}>; +export class AssetRelationship extends AggregateRoot { + private constructor(props: RelationshipProps, id?: UniqueID) { + super(Object.freeze({ ...props }), id); + } + static create( + companyId: UniqueID, + values: AssetRelationshipValues, + actor: UniqueID, + now: string + ): Result { + if (!z.iso.datetime().safeParse(now).success) { + return Result.fail(new AssetCatalogError("INVALID_REQUEST")); + } + return Result.ok( + new AssetRelationship({ + companyId, + values, + createdBy: actor, + createdAt: now, + endedBy: Maybe.none(), + endedAt: Maybe.none(), + }) + ); + } + static rehydrate(props: RelationshipProps, id: UniqueID) { + return new AssetRelationship(props, id); + } + get companyId() { + return this.props.companyId; + } + get values() { + return this.props.values; + } + get createdBy() { + return this.props.createdBy; + } + get createdAt() { + return this.props.createdAt; + } + get endedBy() { + return this.props.endedBy; + } + get endedAt() { + return this.props.endedAt; + } + get active() { + return !this.endedAt.getOrUndefined(); + } + end(actor: UniqueID, now: string): Result { + if (!this.active) { + return Result.ok(this); + } + if (!z.iso.datetime().safeParse(now).success || now < this.createdAt) { + return Result.fail(new AssetCatalogError("INVALID_REQUEST")); + } + return Result.ok( + new AssetRelationship( + { ...this.props, endedBy: Maybe.some(actor), endedAt: Maybe.some(now) }, + this.id + ) + ); + } +} diff --git a/modules/assets/src/api/domain/asset-relationship.test.ts b/modules/assets/src/api/domain/asset-relationship.test.ts new file mode 100644 index 0000000..73d022d --- /dev/null +++ b/modules/assets/src/api/domain/asset-relationship.test.ts @@ -0,0 +1,124 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { UniqueID } from "@repo/rdx-ddd"; +import { Maybe } from "@repo/rdx-utils"; + +import { AssetCatalog } from "./asset-catalog.aggregate"; +import { seedAssetCatalog } from "./asset-catalog-seed"; +import { AssetRelationship } from "./asset-relationship.aggregate"; +import { AssetDefinitionValues } from "./value-objects/asset-definition-values"; +import { + AssetRelationshipValues, + type RelationshipValuesData, +} from "./value-objects/asset-relationship-values"; + +const sourceId = UniqueID.generateNewID().toPrimitive(); +const targetId = UniqueID.generateNewID().toPrimitive(); +const typeId = UniqueID.generateNewID().toPrimitive(); +function input(symmetric = false): RelationshipValuesData { + return { + sourceId, + targetId, + typeId, + name: " Alojado en ", + inverseName: " Aloja ", + symmetric, + notes: " rack 1 ", + }; +} +function values(data = input()) { + const result = AssetRelationshipValues.create(data); + assert.ok(result.isSuccess); + return result.data; +} +test("Las relaciones validan sus extremos, normalizan nombres y distinguen dirección", () => { + const first = values(); + assert.equal(first.toPrimitive().name, "Alojado en"); + assert.equal(first.notes, "rack 1"); + assert.notEqual( + first.key(), + values({ ...input(), sourceId: targetId, targetId: sourceId }).key() + ); + const symmetric = values(input(true)); + assert.equal( + symmetric.key(), + values({ ...input(true), sourceId: targetId, targetId: sourceId }).key() + ); + assert.equal(symmetric.toPrimitive().inverseName, "Alojado en"); + const self = AssetRelationshipValues.create({ ...input(), targetId: sourceId }); + assert.ok(!self.isSuccess); + assert.equal(self.error.code, "SELF_RELATIONSHIP"); + assert.ok(!AssetRelationshipValues.create({ ...input(), targetId: "bad" }).isSuccess); + assert.ok(!AssetRelationshipValues.create({ ...input(), inverseName: " " }).isSuccess); + assert.ok(!AssetRelationshipValues.create({ ...input(), notes: "x".repeat(10001) }).isSuccess); + assert.ok(Object.isFrozen(first)); +}); +test("Retirar conserva UUID, extremos y auditoría; repetir la retirada no modifica el historial", () => { + const company = UniqueID.generateNewID(); + const actor = UniqueID.generateNewID(); + const otherActor = UniqueID.generateNewID(); + const created = AssetRelationship.create(company, values(), actor, "2026-10-04T10:00:00.000Z"); + assert.ok(created.isSuccess); + const link = created.data; + assert.equal(link.id.toPrimitive().charAt(14), "7"); + assert.ok(link.active); + assert.ok(!link.end(otherActor, "2026-10-03T10:00:00.000Z").isSuccess); + const ended = link.end(otherActor, "2026-10-04T11:00:00.000Z"); + assert.ok(ended.isSuccess); + assert.equal(ended.data.active, false); + assert.ok(ended.data.id.equals(link.id)); + assert.equal(ended.data.values, link.values); + assert.ok(ended.data.createdBy.equals(actor)); + assert.ok(ended.data.endedBy.getOrUndefined()?.equals(otherActor)); + const retry = ended.data.end(actor, "2026-10-04T12:00:00.000Z"); + assert.ok(retry.isSuccess); + assert.equal(retry.data, ended.data); + assert.ok(link.active); +}); +test("Tipos de relación de serie protegidos, ámbito de compañía y dirección fija", () => { + const scope = Maybe.some(UniqueID.generateNewID()); + const other = Maybe.some(UniqueID.generateNewID()); + let catalog = seedAssetCatalog(AssetCatalog.create()); + const stock = catalog.definitions.find((item) => item.values.data.kind === "relation"); + assert.ok(stock); + const spec = { + kind: "relation", + name: "Conectado a", + inverseName: "Conectado a", + description: "", + symmetric: true, + } as const; + const custom = AssetDefinitionValues.create({ + ...spec, + name: "Replica a", + inverseName: "Replicado desde", + symmetric: false, + }); + assert.ok(custom.isSuccess); + const protectedChange = catalog.save(Maybe.none(), Maybe.some(stock.id), custom.data); + assert.ok(!protectedChange.isSuccess); + assert.equal(protectedChange.error.code, "BUILTIN_PROTECTED"); + const status = catalog.setActive(scope, stock.id, false); + assert.ok(status.isSuccess); + catalog = status.data; + assert.equal(catalog.active(stock, scope), false); + assert.equal(catalog.active(stock, other), true); + const added = catalog.save(scope, Maybe.none(), custom.data); + assert.ok(added.isSuccess); + catalog = added.data; + const item = catalog.definitions.at(-1); + assert.ok(item); + assert.equal( + catalog.visible(other).some((entry) => entry.id.equals(item.id)), + false + ); + const converted = AssetDefinitionValues.create({ ...spec, name: "Replica a" }); + assert.ok(converted.isSuccess); + const rejected = catalog.save(scope, Maybe.some(item.id), converted.data); + assert.ok(!rejected.isSuccess); + assert.equal(rejected.error.code, "RELATION_DIRECTION_FIXED"); + const unchanged = catalog.save(scope, Maybe.some(item.id), custom.data); + assert.ok(unchanged.isSuccess); + assert.equal(unchanged.data, catalog); +}); diff --git a/modules/assets/src/api/domain/value-objects/asset-definition-values.ts b/modules/assets/src/api/domain/value-objects/asset-definition-values.ts index 272b13f..8d0b89c 100644 --- a/modules/assets/src/api/domain/value-objects/asset-definition-values.ts +++ b/modules/assets/src/api/domain/value-objects/asset-definition-values.ts @@ -41,6 +41,9 @@ export class AssetDefinitionValues { Object.freeze(values); return Result.ok(new AssetDefinitionValues(values, name.data, Object.freeze([]))); } + if (data.kind === "relation") { + return AssetDefinitionValues.relation(data, name.data); + } const ids: UniqueID[] = []; for (const field of data.fields) { const id = UniqueID.create(field.fieldId); @@ -68,6 +71,24 @@ export class AssetDefinitionValues { ) ); } + private static relation(data: Extract, name: Name) { + const inverse = Name.create(data.symmetric ? data.name : data.inverseName, 100); + if (!inverse.isSuccess) { + return Result.fail(new AssetCatalogError("INVALID_REQUEST")); + } + return Result.ok( + new AssetDefinitionValues( + Object.freeze({ + ...data, + name: name.toPrimitive(), + inverseName: inverse.data.toPrimitive(), + description: data.description.trim(), + }), + name, + Object.freeze([]) + ) + ); + } equals(other: AssetDefinitionValues): boolean { const a = this.data; const b = other.data; @@ -82,6 +103,9 @@ export class AssetDefinitionValues { a.choices.every((choice, index) => choice === b.choices[index]) ); } + if (a.kind === "relation" && b.kind === "relation") { + return a.inverseName === b.inverseName && a.symmetric === b.symmetric; + } return ( a.kind === "template" && b.kind === "template" && diff --git a/modules/assets/src/api/domain/value-objects/asset-relationship-values.ts b/modules/assets/src/api/domain/value-objects/asset-relationship-values.ts new file mode 100644 index 0000000..dc3d805 --- /dev/null +++ b/modules/assets/src/api/domain/value-objects/asset-relationship-values.ts @@ -0,0 +1,92 @@ +import { Name, UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import { z } from "zod/v4"; + +import { AssetCatalogError } from "../asset-definition"; + +export const RelationshipValuesSchema = z.strictObject({ + sourceId: z.uuid(), + targetId: z.uuid(), + typeId: z.uuid(), + name: z.string().min(1).max(100), + inverseName: z.string().min(1).max(100), + symmetric: z.boolean(), + notes: z.string().max(10000), +}); +export type RelationshipValuesData = z.infer; + +export class AssetRelationshipValues { + private constructor( + public readonly sourceId: UniqueID, + public readonly targetId: UniqueID, + public readonly typeId: UniqueID, + public readonly name: Name, + public readonly inverseName: Name, + public readonly symmetric: boolean, + public readonly notes: string + ) { + Object.freeze(this); + } + static create(input: RelationshipValuesData): Result { + const parsed = RelationshipValuesSchema.safeParse(input); + const source = UniqueID.create(input.sourceId); + const target = UniqueID.create(input.targetId); + const type = UniqueID.create(input.typeId); + const name = Name.create(input.name, 100); + const inverse = Name.create(input.symmetric ? input.name : input.inverseName, 100); + if ( + !( + parsed.success && + source.isSuccess && + target.isSuccess && + type.isSuccess && + name.isSuccess && + inverse.isSuccess + ) + ) { + return Result.fail(new AssetCatalogError("INVALID_REQUEST")); + } + if (source.data.equals(target.data)) { + return Result.fail(new AssetCatalogError("SELF_RELATIONSHIP")); + } + return Result.ok( + new AssetRelationshipValues( + source.data, + target.data, + type.data, + name.data, + inverse.data, + input.symmetric, + input.notes.trim() + ) + ); + } + toPrimitive(): RelationshipValuesData { + return { + sourceId: this.sourceId.toPrimitive(), + targetId: this.targetId.toPrimitive(), + typeId: this.typeId.toPrimitive(), + name: this.name.toPrimitive(), + inverseName: this.inverseName.toPrimitive(), + symmetric: this.symmetric, + notes: this.notes, + }; + } + equals(other: AssetRelationshipValues): boolean { + return ( + this.key() === other.key() && + this.name.equals(other.name) && + this.inverseName.equals(other.inverseName) && + this.symmetric === other.symmetric && + this.notes === other.notes + ); + } + /** Identifica un vínculo; las relaciones simétricas normalizan el orden de sus extremos. */ + key(): string { + const ids = [this.sourceId.toPrimitive(), this.targetId.toPrimitive()]; + if (this.symmetric) { + ids.sort(); + } + return [this.typeId.toPrimitive(), ...ids].join(":"); + } +} diff --git a/modules/assets/src/api/domain/value-objects/workstation-values.ts b/modules/assets/src/api/domain/value-objects/workstation-values.ts new file mode 100644 index 0000000..e7ccc61 --- /dev/null +++ b/modules/assets/src/api/domain/value-objects/workstation-values.ts @@ -0,0 +1,67 @@ +import { Name, UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { AssetCatalogError } from "../asset-definition"; +import { type WorkstationInput, WorkstationInputSchema } from "../workstation-values"; +export class WorkstationValues { + private constructor( + public readonly name: Name, + public readonly active: boolean, + public readonly siteId: Maybe, + public readonly personId: Maybe, + public readonly assetIds: readonly UniqueID[] + ) { + Object.freeze(this); + } + static create(input: WorkstationInput): Result { + const parsed = WorkstationInputSchema.safeParse(input); + const name = Name.create(input.name, 100); + const site = optionalIdentity(input.siteId); + const person = optionalIdentity(input.personId); + if (!(parsed.success && name.isSuccess && site.isSuccess && person.isSuccess)) { + return Result.fail(new AssetCatalogError("INVALID_REQUEST")); + } + const ids: UniqueID[] = []; + for (const value of parsed.data.assetIds) { + const id = UniqueID.create(value); + if (!id.isSuccess || ids.some((previous) => previous.equals(id.data))) { + return Result.fail(new AssetCatalogError("INVALID_MEMBERS")); + } + ids.push(id.data); + } + return Result.ok( + new WorkstationValues(name.data, input.active, site.data, person.data, Object.freeze(ids)) + ); + } + toPrimitive(): WorkstationInput { + return { + name: this.name.toPrimitive(), + active: this.active, + siteId: this.siteId.getOrUndefined()?.toPrimitive() ?? null, + personId: this.personId.getOrUndefined()?.toPrimitive() ?? null, + assetIds: this.assetIds.map((id) => id.toPrimitive()), + }; + } + equals(other: WorkstationValues) { + const a = this.toPrimitive(); + const b = other.toPrimitive(); + return ( + a.name === b.name && + a.active === b.active && + a.siteId === b.siteId && + a.personId === b.personId && + a.assetIds.length === b.assetIds.length && + a.assetIds.every((id) => b.assetIds.includes(id)) + ); + } +} + +function optionalIdentity(value: string | null): Result, AssetCatalogError> { + if (!value) { + return Result.ok(Maybe.none()); + } + const id = UniqueID.create(value); + return id.isSuccess + ? Result.ok(Maybe.some(id.data)) + : Result.fail(new AssetCatalogError("INVALID_REQUEST")); +} diff --git a/modules/assets/src/api/domain/workstation-revision.entity.ts b/modules/assets/src/api/domain/workstation-revision.entity.ts new file mode 100644 index 0000000..00adc25 --- /dev/null +++ b/modules/assets/src/api/domain/workstation-revision.entity.ts @@ -0,0 +1,25 @@ +import { DomainEntity, type UniqueID } from "@repo/rdx-ddd"; + +import type { WorkstationValues } from "./value-objects/workstation-values"; +export type WorkstationRevisionProps = Readonly<{ + values: WorkstationValues; + actorId: UniqueID; + recordedAt: string; +}>; +export class WorkstationRevision extends DomainEntity { + static create(props: WorkstationRevisionProps) { + return new WorkstationRevision(Object.freeze({ ...props })); + } + static rehydrate(props: WorkstationRevisionProps, id: UniqueID) { + return new WorkstationRevision(Object.freeze({ ...props }), id); + } + get values() { + return this.props.values; + } + get actorId() { + return this.props.actorId; + } + get recordedAt() { + return this.props.recordedAt; + } +} diff --git a/modules/assets/src/api/domain/workstation-values.ts b/modules/assets/src/api/domain/workstation-values.ts new file mode 100644 index 0000000..27f43a5 --- /dev/null +++ b/modules/assets/src/api/domain/workstation-values.ts @@ -0,0 +1,9 @@ +import { z } from "zod/v4"; +export const WorkstationInputSchema = z.strictObject({ + name: z.string().min(1).max(100), + active: z.boolean(), + siteId: z.uuid().nullable(), + personId: z.uuid().nullable(), + assetIds: z.array(z.uuid()).max(200), +}); +export type WorkstationInput = z.infer; diff --git a/modules/assets/src/api/domain/workstation.aggregate.ts b/modules/assets/src/api/domain/workstation.aggregate.ts new file mode 100644 index 0000000..181880b --- /dev/null +++ b/modules/assets/src/api/domain/workstation.aggregate.ts @@ -0,0 +1,72 @@ +import { AggregateRoot, type UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import { z } from "zod/v4"; + +import { AssetCatalogError } from "./asset-definition"; +import type { WorkstationValues } from "./value-objects/workstation-values"; +import { WorkstationRevision } from "./workstation-revision.entity"; + +type WorkstationProps = Readonly<{ + companyId: UniqueID; + values: WorkstationValues; + history: readonly WorkstationRevision[]; +}>; +export class Workstation extends AggregateRoot { + private constructor(props: WorkstationProps, id?: UniqueID) { + super(Object.freeze({ ...props, history: Object.freeze([...props.history]) }), id); + } + static create( + companyId: UniqueID, + values: WorkstationValues, + actorId: UniqueID, + recordedAt: string + ): Result { + if (!z.iso.datetime().safeParse(recordedAt).success) { + return Result.fail(new AssetCatalogError("INVALID_REQUEST")); + } + return Result.ok( + new Workstation({ + companyId, + values, + history: [WorkstationRevision.create({ values, actorId, recordedAt })], + }) + ); + } + static rehydrate(props: WorkstationProps, id: UniqueID) { + return new Workstation(props, id); + } + get companyId() { + return this.props.companyId; + } + get values() { + return this.props.values; + } + get history() { + return this.props.history; + } + update( + values: WorkstationValues, + actorId: UniqueID, + recordedAt: string + ): Result { + if (this.values.equals(values)) { + return Result.ok(this); + } + if ( + !z.iso.datetime().safeParse(recordedAt).success || + recordedAt < (this.history.at(-1)?.recordedAt ?? "") + ) { + return Result.fail(new AssetCatalogError("INVALID_REQUEST")); + } + return Result.ok( + new Workstation( + { + ...this.props, + values, + history: [...this.history, WorkstationRevision.create({ values, actorId, recordedAt })], + }, + this.id + ) + ); + } +} diff --git a/modules/assets/src/api/domain/workstation.test.ts b/modules/assets/src/api/domain/workstation.test.ts new file mode 100644 index 0000000..b652500 --- /dev/null +++ b/modules/assets/src/api/domain/workstation.test.ts @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { UniqueID } from "@repo/rdx-ddd"; + +import { WorkstationValues } from "./value-objects/workstation-values"; +import { Workstation } from "./workstation.aggregate"; +import type { WorkstationInput } from "./workstation-values"; + +const assetIds = [UniqueID.generateNewID().toPrimitive(), UniqueID.generateNewID().toPrimitive()]; +function input(): WorkstationInput { + return { name: " Puesto Ana ", active: true, siteId: null, personId: null, assetIds }; +} +function values(data = input()) { + const result = WorkstationValues.create(data); + assert.ok(result.isSuccess); + return result.data; +} +test("Puestos: composición opcional, referencias válidas y límites sin duplicados", () => { + assert.ok(WorkstationValues.create({ ...input(), assetIds: [] }).isSuccess); + assert.equal(values().name.toPrimitive(), "Puesto Ana"); + assert.ok(Object.isFrozen(values().assetIds)); + assert.ok(!WorkstationValues.create({ ...input(), name: " " }).isSuccess); + assert.ok(!WorkstationValues.create({ ...input(), personId: "invalid" }).isSuccess); + const duplicate = WorkstationValues.create({ + ...input(), + assetIds: [assetIds[0] ?? "", assetIds[0] ?? ""], + }); + assert.ok(!duplicate.isSuccess); + assert.equal(duplicate.error.code, "INVALID_MEMBERS"); + assert.ok( + !WorkstationValues.create({ + ...input(), + assetIds: Array.from({ length: 201 }, () => UniqueID.generateNewID().toPrimitive()), + }).isSuccess + ); + assert.ok(values().equals(values({ ...input(), assetIds: [...assetIds].reverse() }))); +}); +test("Puestos: UUID estable e historial de composición, persona y estado; guardado idéntico idempotente", () => { + const company = UniqueID.generateNewID(); + const actor = UniqueID.generateNewID(); + const created = Workstation.create(company, values(), actor, "2026-10-04T10:00:00.000Z"); + assert.ok(created.isSuccess); + const item = created.data; + assert.equal(item.id.toPrimitive().charAt(14), "7"); + assert.equal(item.history[0]?.id.toPrimitive().charAt(14), "7"); + const same = item.update( + values({ ...input(), assetIds: [...assetIds].reverse() }), + actor, + "2026-10-04T11:00:00.000Z" + ); + assert.ok(same.isSuccess); + assert.equal(same.data, item); + const changed = item.update( + values({ ...input(), personId: actor.toPrimitive(), active: false, assetIds: [] }), + actor, + "2026-10-04T11:00:00.000Z" + ); + assert.ok(changed.isSuccess); + assert.ok(changed.data.id.equals(item.id)); + assert.equal(changed.data.history.length, 2); + assert.equal(changed.data.history[0]?.values.toPrimitive().personId, null); + assert.deepEqual(changed.data.history[0]?.values.toPrimitive().assetIds, assetIds); + assert.ok(!changed.data.update(values(), actor, "2026-10-03T10:00:00.000Z").isSuccess); + assert.equal( + Workstation.rehydrate( + { companyId: company, values: changed.data.values, history: changed.data.history }, + changed.data.id + ).id.toPrimitive(), + item.id.toPrimitive() + ); +}); diff --git a/modules/assets/src/api/infrastructure/http/asset-relationship.routes.ts b/modules/assets/src/api/infrastructure/http/asset-relationship.routes.ts new file mode 100644 index 0000000..b2add85 --- /dev/null +++ b/modules/assets/src/api/infrastructure/http/asset-relationship.routes.ts @@ -0,0 +1,81 @@ +import type { Application, RequestHandler, Response } from "express"; +import { Router } from "express"; +import { z } from "zod/v4"; + +import { AssetParamsSchema, InventoryParamsSchema } from "../../../common/inventory"; +import { RelationshipRequestSchema } from "../../../common/relationships"; +import type { AssetRelationshipUseCases } from "../../application/asset-relationship.use-cases"; +import { buildRelationshipResponse } from "../../application/asset-relationship-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 registerRelationshipRoutes( + app: Application, + useCases: AssetRelationshipUseCases, + authenticate: RequestHandler +) { + const router = Router(); + router.post("/:companyId/relationships", (request, response, next) => { + const params = InventoryParamsSchema.safeParse(request.params); + const body = RelationshipRequestSchema.safeParse(request.body); + const actor = z + .object({ userId: z.uuid() }) + .safeParse("session" in request ? request.session : undefined); + if ( + !( + params.success && + body.success && + actor.success && + z.strictObject({}).safeParse(request.query).success + ) + ) { + fail(response, "INVALID_REQUEST"); + return; + } + useCases + .save(params.data.companyId, body.data.values, actor.data.userId) + .then((result) => { + if (!result.isSuccess) { + fail(response, result.error.code); + return; + } + response.status(201).json({ + relationship: buildRelationshipResponse(result.data.relationship, result.data.actorNames), + }); + }) + .catch(next); + }); + router.post("/:companyId/relationships/:id/end", (request, response, next) => { + const params = AssetParamsSchema.safeParse(request.params); + const actor = z + .object({ userId: z.uuid() }) + .safeParse("session" in request ? request.session : undefined); + if ( + !( + params.success && + actor.success && + z.strictObject({}).safeParse(request.body).success && + z.strictObject({}).safeParse(request.query).success + ) + ) { + fail(response, "INVALID_REQUEST"); + return; + } + useCases + .end(params.data.companyId, params.data.id, actor.data.userId) + .then((result) => { + if (!result.isSuccess) { + fail(response, result.error.code); + return; + } + response.json({ + relationship: buildRelationshipResponse(result.data.relationship, result.data.actorNames), + }); + }) + .catch(next); + }); + app.use("/api/assets", authenticate, router); +} diff --git a/modules/assets/src/api/infrastructure/http/asset.routes.ts b/modules/assets/src/api/infrastructure/http/asset.routes.ts index 9521b1f..9974cfa 100644 --- a/modules/assets/src/api/infrastructure/http/asset.routes.ts +++ b/modules/assets/src/api/infrastructure/http/asset.routes.ts @@ -8,7 +8,9 @@ import { SaveAssetRequestSchema, } from "../../../common/inventory"; import type { AssetUseCases } from "../../application/asset.use-cases"; +import { buildRelationshipResponse } from "../../application/asset-relationship-response.builder"; import { buildAssetResponse } from "../../application/asset-response.builder"; +import { buildWorkstationResponse } from "../../application/workstation-response.builder"; function fail(response: Response, code: string) { response @@ -35,6 +37,12 @@ export function registerAssetRoutes( return; } response.json({ + workstations: result.data.workstations.map((item) => + buildWorkstationResponse(item, result.data.actorNames) + ), + relationships: result.data.relationships.map((item) => + buildRelationshipResponse(item, result.data.actorNames) + ), assets: result.data.assets.map((asset) => buildAssetResponse(asset, result.data.actorNames) ), diff --git a/modules/assets/src/api/infrastructure/http/workstation.routes.ts b/modules/assets/src/api/infrastructure/http/workstation.routes.ts new file mode 100644 index 0000000..40b6ca8 --- /dev/null +++ b/modules/assets/src/api/infrastructure/http/workstation.routes.ts @@ -0,0 +1,58 @@ +import type { Application, RequestHandler, Response } from "express"; +import { Router } from "express"; +import { z } from "zod/v4"; + +import { AssetParamsSchema, InventoryParamsSchema } from "../../../common/inventory"; +import { SaveWorkstationRequestSchema } from "../../../common/workstations"; +import type { WorkstationUseCases } from "../../application/workstation.use-cases"; +import { buildWorkstationResponse } from "../../application/workstation-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 registerWorkstationRoutes( + app: Application, + useCases: WorkstationUseCases, + authenticate: RequestHandler +) { + const router = Router(); + for (const path of ["/:companyId/workstations", "/:companyId/workstations/:id/update"]) { + router.post(path, (request, response, next) => { + const params = ( + path.endsWith("update") ? AssetParamsSchema : InventoryParamsSchema + ).safeParse(request.params); + const body = SaveWorkstationRequestSchema.safeParse(request.body); + const actor = z + .object({ userId: z.uuid() }) + .safeParse("session" in request ? request.session : undefined); + if ( + !( + params.success && + body.success && + actor.success && + z.strictObject({}).safeParse(request.query).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({ + workstation: buildWorkstationResponse(result.data.workstation, result.data.actorNames), + }); + }) + .catch(next); + }); + } + app.use("/api/assets", authenticate, router); +} diff --git a/modules/assets/src/api/infrastructure/persistence/asset-relationship-persistence.mapper.ts b/modules/assets/src/api/infrastructure/persistence/asset-relationship-persistence.mapper.ts new file mode 100644 index 0000000..f49ad05 --- /dev/null +++ b/modules/assets/src/api/infrastructure/persistence/asset-relationship-persistence.mapper.ts @@ -0,0 +1,61 @@ +import { Maybe } from "@repo/rdx-utils"; +import { z } from "zod/v4"; + +import { AssetRelationship } from "../../domain/asset-relationship.aggregate"; +import { + AssetRelationshipValues, + RelationshipValuesSchema, +} from "../../domain/value-objects/asset-relationship-values"; + +import { inventoryIdentity } from "./asset-persistence.mapper"; +import type { RelationshipModel } from "./asset-relationship.model"; + +export class AssetRelationshipPersistenceMapper { + toDomain(row: RelationshipModel) { + const values = AssetRelationshipValues.create(RelationshipValuesSchema.parse(row.values)); + if ( + !(values.isSuccess && z.iso.datetime().safeParse(row.createdAt).success) || + (row.endedAt !== null && + (!z.iso.datetime().safeParse(row.endedAt).success || row.endedAt < row.createdAt)) || + (row.endedAt === null) !== (row.endedBy === null) + ) { + throw new Error("Relación de activos persistida inconsistente."); + } + const item = AssetRelationship.rehydrate( + { + companyId: inventoryIdentity(row.companyId), + values: values.data, + createdBy: inventoryIdentity(row.createdBy), + createdAt: row.createdAt, + endedBy: row.endedBy ? Maybe.some(inventoryIdentity(row.endedBy)) : Maybe.none(), + endedAt: row.endedAt ? Maybe.some(row.endedAt) : Maybe.none(), + }, + inventoryIdentity(row.id) + ); + if ( + row.sourceId !== item.values.sourceId.toPrimitive() || + row.targetId !== item.values.targetId.toPrimitive() || + row.typeId !== item.values.typeId.toPrimitive() || + row.activeKey !== (item.active ? `${row.companyId}:${item.values.key()}` : null) + ) { + throw new Error("Referencias de relación persistidas inconsistentes."); + } + return item; + } + toPersistence(item: AssetRelationship) { + const values = item.values.toPrimitive(); + return { + id: item.id.toPrimitive(), + companyId: item.companyId.toPrimitive(), + sourceId: values.sourceId, + targetId: values.targetId, + typeId: values.typeId, + values, + createdBy: item.createdBy.toPrimitive(), + createdAt: item.createdAt, + endedBy: item.endedBy.getOrUndefined()?.toPrimitive() ?? null, + endedAt: item.endedAt.getOrUndefined() ?? null, + activeKey: item.active ? `${item.companyId.toPrimitive()}:${item.values.key()}` : null, + }; + } +} diff --git a/modules/assets/src/api/infrastructure/persistence/asset-relationship.model.ts b/modules/assets/src/api/infrastructure/persistence/asset-relationship.model.ts new file mode 100644 index 0000000..9fc49d0 --- /dev/null +++ b/modules/assets/src/api/infrastructure/persistence/asset-relationship.model.ts @@ -0,0 +1,77 @@ +import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize"; +import { DataTypes, Model, QueryTypes } from "sequelize"; +export class RelationshipModel extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: string; + declare companyId: string; + declare sourceId: string; + declare targetId: string; + declare typeId: string; + declare values: unknown; + declare createdBy: string; + declare createdAt: string; + declare endedBy: string | null; + declare endedAt: string | null; + declare activeKey: string | null; +} +export async function migrateRelationships(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 fk = (model: string, nullable = false) => ({ + type: DataTypes.UUID, + allowNull: nullable, + references: { model, key: "id" }, + onDelete: "RESTRICT", + }); + const columns = { + id: { type: DataTypes.UUID, primaryKey: true }, + companyId: fk("companies"), + sourceId: fk("assets"), + targetId: fk("assets"), + typeId: fk("asset_definitions"), + values: { type: DataTypes.JSON, allowNull: false }, + createdBy: fk("users"), + createdAt: { type: DataTypes.STRING(30), allowNull: false }, + endedBy: fk("users", true), + endedAt: { type: DataTypes.STRING(30), allowNull: true }, + activeKey: { type: DataTypes.STRING(150), allowNull: true, unique: true }, + }; + if (!tables.some((table) => table.name === "asset_relationships")) { + await query.createTable("asset_relationships", columns); + } + const indexes = await database.query<{ name: string }>( + "SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='assets'", + { type: QueryTypes.SELECT } + ); + if (!indexes.some((index) => index.name === "assets_company_identity")) { + await query.addIndex("assets", ["companyId", "id"], { + unique: true, + name: "assets_company_identity", + }); + } + const constraints = await database.query<{ name: string }>( + "SELECT CONSTRAINT_NAME AS name FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='asset_relationships'", + { type: QueryTypes.SELECT } + ); + // Las FK compuestas impiden vínculos cruzados también ante escrituras externas al dominio. + for (const [column, name] of [ + ["sourceId", "relationship_source_company"], + ["targetId", "relationship_target_company"], + ] as const) { + if (!constraints.some((constraint) => constraint.name === name)) { + await database.query( + `ALTER TABLE asset_relationships ADD CONSTRAINT \`${name}\` FOREIGN KEY (companyId, \`${column}\`) REFERENCES assets(companyId,id)` + ); + } + } + RelationshipModel.init(columns, { + sequelize: database, + tableName: "asset_relationships", + timestamps: false, + }); +} diff --git a/modules/assets/src/api/infrastructure/persistence/load-workstations.ts b/modules/assets/src/api/infrastructure/persistence/load-workstations.ts new file mode 100644 index 0000000..6074ba7 --- /dev/null +++ b/modules/assets/src/api/infrastructure/persistence/load-workstations.ts @@ -0,0 +1,51 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import type { Transaction } from "sequelize"; + +import type { Asset } from "../../domain/asset.aggregate"; + +import { + WorkstationAssetModel, + WorkstationModel, + WorkstationRevisionModel, +} from "./workstation.model"; +import { WorkstationPersistenceMapper } from "./workstation-persistence.mapper"; +export async function loadWorkstations( + companyId: UniqueID, + transaction: Transaction, + assets: readonly Asset[], + sites: readonly { id: string }[], + people: readonly { id: string }[] +) { + const rows = await WorkstationModel.findAll({ + where: { companyId: companyId.toPrimitive() }, + transaction, + }); + const revisions = rows.length + ? await WorkstationRevisionModel.findAll({ + where: { workstationId: rows.map((item) => item.id) }, + transaction, + }) + : []; + const references = await WorkstationAssetModel.findAll({ + where: { companyId: companyId.toPrimitive() }, + transaction, + }); + return rows.map((row) => { + const item = new WorkstationPersistenceMapper().toDomain( + row, + revisions.filter((revision) => revision.workstationId === row.id), + references.filter((reference) => reference.workstationId === row.id) + ); + for (const revision of item.history) { + const values = revision.values.toPrimitive(); + if ( + (values.siteId && !sites.some((site) => site.id === values.siteId)) || + (values.personId && !people.some((person) => person.id === values.personId)) || + values.assetIds.some((id) => !assets.some((asset) => asset.id.toPrimitive() === id)) + ) { + throw new Error("Puesto de trabajo fuera de su compañía."); + } + } + return item; + }); +} diff --git a/modules/assets/src/api/infrastructure/persistence/sequelize-asset.repository.ts b/modules/assets/src/api/infrastructure/persistence/sequelize-asset.repository.ts index 14ff851..1f3519b 100644 --- a/modules/assets/src/api/infrastructure/persistence/sequelize-asset.repository.ts +++ b/modules/assets/src/api/infrastructure/persistence/sequelize-asset.repository.ts @@ -1,10 +1,11 @@ import type { UniqueID } from "@repo/rdx-ddd"; -import { Result } from "@repo/rdx-utils"; +import { Maybe, Result } from "@repo/rdx-utils"; import type { Sequelize, Transaction } from "sequelize"; import { QueryTypes } from "sequelize"; import type { IAssetRepository, InventoryContext } from "../../application/asset.repository"; import { AssetCatalogError } from "../../domain/asset-definition"; +import type { Workstation } from "../../domain/workstation.aggregate"; import { AssetModel, AssetRevisionModel } from "./asset.model"; import { @@ -15,6 +16,15 @@ import { } from "./asset-catalog.model"; import { AssetCatalogPersistenceMapper } from "./asset-catalog-persistence.mapper"; import { AssetPersistenceMapper, inventoryIdentity } from "./asset-persistence.mapper"; +import { RelationshipModel } from "./asset-relationship.model"; +import { AssetRelationshipPersistenceMapper } from "./asset-relationship-persistence.mapper"; +import { loadWorkstations } from "./load-workstations"; +import { + WorkstationAssetModel, + WorkstationModel, + WorkstationRevisionModel, +} from "./workstation.model"; +import { WorkstationPersistenceMapper } from "./workstation-persistence.mapper"; export class SequelizeAssetRepository implements IAssetRepository { private readonly mapper = new AssetPersistenceMapper(); constructor(private readonly database: Sequelize) {} @@ -106,7 +116,34 @@ export class SequelizeAssetRepository implements IAssetRepository { } return asset; }); + const relationships = ( + await RelationshipModel.findAll({ + where: { companyId: companyId.toPrimitive() }, + transaction, + }) + ).map((row) => new AssetRelationshipPersistenceMapper().toDomain(row)); + for (const relationship of relationships) { + const values = relationship.values; + const definition = catalog + .visible(Maybe.some(companyId)) + .find((item) => item.id.equals(values.typeId)); + if ( + !( + definition && + definition.values.data.kind === "relation" && + definition.values.data.symmetric === values.symmetric && + [values.sourceId, values.targetId].every((id) => + assets.some((asset) => asset.id.equals(id)) + ) + ) + ) { + throw new Error("Relación de activos fuera de su compañía."); + } + } + const workstations = await loadWorkstations(companyId, transaction, assets, sites, people); return Result.ok({ + workstations, + relationships, catalog, assets, sites: sites.map((site) => inventoryIdentity(site.id)), @@ -117,6 +154,63 @@ export class SequelizeAssetRepository implements IAssetRepository { actorNames: new Map(actors.map((actor) => [actor.id, actor.name])), }); } + saveWorkstation(companyId: UniqueID, action: Parameters[1]) { + return this.database.transaction(async (transaction) => { + const loaded = await this.load(companyId, transaction, true); + if (!loaded.isSuccess) { + return Result.fail(loaded.error); + } + const changed = action(loaded.data); + if (!changed.isSuccess) { + return Result.fail(changed.error); + } + const previous = loaded.data.workstations.find((item) => item.id.equals(changed.data.id)); + if (previous !== changed.data) { + await this.persistWorkstation(changed.data, previous, transaction); + } + return Result.ok({ workstation: changed.data, actorNames: loaded.data.actorNames }); + }); + } + private async persistWorkstation( + item: Workstation, + previous: Workstation | undefined, + transaction: Transaction + ) { + const rows = new WorkstationPersistenceMapper().toPersistence(item); + await WorkstationModel.upsert(rows.workstation, { transaction }); + const existing = new Set(previous?.history.map((item) => item.id.toPrimitive())); + for (const revision of rows.revisions) { + if (!existing.has(revision.id)) { + await WorkstationRevisionModel.create(revision, { transaction }); + } + } + for (const reference of rows.references) { + await WorkstationAssetModel.upsert(reference, { transaction }); + } + } + saveRelationship( + companyId: UniqueID, + action: Parameters[1] + ) { + return this.database.transaction(async (transaction) => { + const loaded = await this.load(companyId, transaction, true); + if (!loaded.isSuccess) { + return Result.fail(loaded.error); + } + const changed = action(loaded.data); + if (!changed.isSuccess) { + return Result.fail(changed.error); + } + const previous = loaded.data.relationships.find((item) => item.id.equals(changed.data.id)); + if (previous !== changed.data) { + await RelationshipModel.upsert( + new AssetRelationshipPersistenceMapper().toPersistence(changed.data), + { transaction } + ); + } + return Result.ok({ relationship: changed.data, actorNames: loaded.data.actorNames }); + }); + } get(companyId: UniqueID) { return this.database.transaction((transaction) => this.load(companyId, transaction, false)); } diff --git a/modules/assets/src/api/infrastructure/persistence/workstation-persistence.mapper.ts b/modules/assets/src/api/infrastructure/persistence/workstation-persistence.mapper.ts new file mode 100644 index 0000000..1c2f831 --- /dev/null +++ b/modules/assets/src/api/infrastructure/persistence/workstation-persistence.mapper.ts @@ -0,0 +1,101 @@ +import { z } from "zod/v4"; + +import { WorkstationValues } from "../../domain/value-objects/workstation-values"; +import { Workstation } from "../../domain/workstation.aggregate"; +import { WorkstationRevision } from "../../domain/workstation-revision.entity"; +import { WorkstationInputSchema } from "../../domain/workstation-values"; + +import { inventoryIdentity } from "./asset-persistence.mapper"; +import type { + WorkstationAssetModel, + WorkstationModel, + WorkstationRevisionModel, +} from "./workstation.model"; + +function valuesOf(raw: unknown, siteId: string | null, personId: string | null) { + const values = WorkstationValues.create(WorkstationInputSchema.parse(raw)); + if ( + !values.isSuccess || + values.data.toPrimitive().siteId !== siteId || + values.data.toPrimitive().personId !== personId + ) { + throw new Error("Valores de puesto persistidos inconsistentes."); + } + return values.data; +} +export class WorkstationPersistenceMapper { + toDomain( + row: WorkstationModel, + revisions: readonly WorkstationRevisionModel[], + references: readonly WorkstationAssetModel[] + ) { + const values = valuesOf(row.values, row.siteId, row.personId); + const ordered = [...revisions].sort((a, b) => a.position - b.position); + const history = ordered.map((revision, position) => { + if ( + revision.position !== position || + !z.iso.datetime().safeParse(revision.recordedAt).success || + revision.recordedAt < (ordered[position - 1]?.recordedAt ?? "") + ) { + throw new Error("Historial de puesto inconsistente."); + } + return WorkstationRevision.rehydrate( + { + values: valuesOf(revision.values, revision.siteId, revision.personId), + actorId: inventoryIdentity(revision.actorId), + recordedAt: revision.recordedAt, + }, + inventoryIdentity(revision.id) + ); + }); + if (!(history.length && history.at(-1)?.values.equals(values))) { + throw new Error("Última revisión de puesto inconsistente."); + } + const expected = new Set(history.flatMap((item) => item.values.toPrimitive().assetIds)); + if ( + references.length !== expected.size || + references.some( + (item) => + item.companyId !== row.companyId || + !expected.has(item.assetId) || + item.activeKey !== + (values.active && values.assetIds.some((id) => id.toPrimitive() === item.assetId) + ? `${row.companyId}:${item.assetId}` + : null) + ) + ) { + throw new Error("Referencias de puesto inconsistentes."); + } + return Workstation.rehydrate( + { companyId: inventoryIdentity(row.companyId), values, history }, + inventoryIdentity(row.id) + ); + } + toPersistence(item: Workstation) { + const values = item.values.toPrimitive(); + const companyId = item.companyId.toPrimitive(); + const id = item.id.toPrimitive(); + return { + workstation: { id, companyId, values, siteId: values.siteId, personId: values.personId }, + revisions: item.history.map((revision, position) => ({ + id: revision.id.toPrimitive(), + workstationId: id, + actorId: revision.actorId.toPrimitive(), + recordedAt: revision.recordedAt, + values: revision.values.toPrimitive(), + position, + siteId: revision.values.toPrimitive().siteId, + personId: revision.values.toPrimitive().personId, + })), + references: [ + ...new Set(item.history.flatMap((revision) => revision.values.toPrimitive().assetIds)), + ].map((assetId) => ({ + workstationId: id, + companyId, + assetId, + activeKey: + values.active && values.assetIds.includes(assetId) ? `${companyId}:${assetId}` : null, + })), + }; + } +} diff --git a/modules/assets/src/api/infrastructure/persistence/workstation.model.ts b/modules/assets/src/api/infrastructure/persistence/workstation.model.ts new file mode 100644 index 0000000..a824320 --- /dev/null +++ b/modules/assets/src/api/infrastructure/persistence/workstation.model.ts @@ -0,0 +1,119 @@ +import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize"; +import { DataTypes, Model, QueryTypes } from "sequelize"; +export class WorkstationModel extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: string; + declare companyId: string; + declare siteId: string | null; + declare personId: string | null; + declare values: unknown; +} +export class WorkstationRevisionModel extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: string; + declare workstationId: string; + declare actorId: string; + declare recordedAt: string; + declare values: unknown; + declare position: number; + declare siteId: string | null; + declare personId: string | null; +} +/** Proyección técnica de referencias históricas y reserva vigente; no representa otra entidad de negocio. */ +export class WorkstationAssetModel extends Model< + InferAttributes, + InferCreationAttributes +> { + declare workstationId: string; + declare companyId: string; + declare assetId: string; + declare activeKey: string | null; +} +export async function migrateWorkstations(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 fk = (model: string, nullable = false) => ({ + type: DataTypes.UUID, + allowNull: nullable, + references: { model, key: "id" }, + onDelete: "RESTRICT", + }); + const root = { + id: { type: DataTypes.UUID, primaryKey: true }, + companyId: fk("companies"), + siteId: fk("company_sites", true), + personId: fk("company_contacts", true), + values: { type: DataTypes.JSON, allowNull: false }, + }; + const revision = { + id: { type: DataTypes.UUID, primaryKey: true }, + workstationId: fk("workstations"), + actorId: fk("users"), + siteId: fk("company_sites", true), + personId: fk("company_contacts", true), + values: { type: DataTypes.JSON, allowNull: false }, + recordedAt: { type: DataTypes.STRING(30), allowNull: false }, + position: { type: DataTypes.INTEGER, allowNull: false }, + }; + const reference = { + workstationId: { ...fk("workstations"), primaryKey: true }, + companyId: fk("companies"), + assetId: { ...fk("assets"), primaryKey: true }, + activeKey: { type: DataTypes.STRING(80), unique: true, allowNull: true }, + }; + for (const [table, columns] of [ + ["workstations", root], + ["workstation_revisions", revision], + ["workstation_assets", reference], + ] as const) { + if (!tables.some((item) => item.name === table)) { + await query.createTable(table, columns); + } + } + const indexes = await database.query<{ name: string }>( + "SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='workstations'", + { type: QueryTypes.SELECT } + ); + if (!indexes.some((item) => item.name === "workstations_company_identity")) { + await query.addIndex("workstations", ["companyId", "id"], { + name: "workstations_company_identity", + unique: true, + }); + } + const constraints = await database.query<{ name: string }>( + "SELECT CONSTRAINT_NAME AS name FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='workstation_assets'", + { type: QueryTypes.SELECT } + ); + if (!constraints.some((item) => item.name === "workstation_asset_company")) { + await database.query( + "ALTER TABLE workstation_assets ADD CONSTRAINT workstation_asset_company FOREIGN KEY (companyId, assetId) REFERENCES assets(companyId,id)" + ); + } + if (!constraints.some((item) => item.name === "workstation_owner_company")) { + await database.query( + "ALTER TABLE workstation_assets ADD CONSTRAINT workstation_owner_company FOREIGN KEY (companyId, workstationId) REFERENCES workstations(companyId,id)" + ); + } + WorkstationModel.init(root, { + sequelize: database, + tableName: "workstations", + timestamps: false, + }); + WorkstationRevisionModel.init(revision, { + sequelize: database, + tableName: "workstation_revisions", + timestamps: false, + }); + WorkstationAssetModel.init(reference, { + sequelize: database, + tableName: "workstation_assets", + timestamps: false, + }); +} diff --git a/modules/assets/src/api/module.ts b/modules/assets/src/api/module.ts index 58c6157..a7ef178 100644 --- a/modules/assets/src/api/module.ts +++ b/modules/assets/src/api/module.ts @@ -5,17 +5,23 @@ import type { Sequelize } from "sequelize"; import { AssetUseCases } from "./application/asset.use-cases"; import { AssetCatalogUseCases } from "./application/asset-catalog.use-cases"; +import { AssetRelationshipUseCases } from "./application/asset-relationship.use-cases"; +import { WorkstationUseCases } from "./application/workstation.use-cases"; import { seedAssetCatalog } from "./domain/asset-catalog-seed"; import { registerAssetRoutes } from "./infrastructure/http/asset.routes"; import { registerAssetCatalogRoutes } from "./infrastructure/http/asset-catalog.routes"; +import { registerRelationshipRoutes } from "./infrastructure/http/asset-relationship.routes"; +import { registerWorkstationRoutes } from "./infrastructure/http/workstation.routes"; import { migrateInventory } from "./infrastructure/persistence/asset.model"; import { CatalogModel, migrateAssetCatalog, } from "./infrastructure/persistence/asset-catalog.model"; import { AssetCatalogPersistenceMapper } from "./infrastructure/persistence/asset-catalog-persistence.mapper"; +import { migrateRelationships } from "./infrastructure/persistence/asset-relationship.model"; import { SequelizeAssetRepository } from "./infrastructure/persistence/sequelize-asset.repository"; import { SequelizeAssetCatalogRepository } from "./infrastructure/persistence/sequelize-asset-catalog.repository"; +import { migrateWorkstations } from "./infrastructure/persistence/workstation.model"; export async function createAssetModule(database: Sequelize, authenticate: RequestHandler) { await migrateAssetCatalog(database); await CatalogModel.findOrCreate({ @@ -33,12 +39,19 @@ export async function createAssetModule(database: Sequelize, authenticate: Reque throw new Error("No se pudo inicializar el catálogo de activos."); } await migrateInventory(database); - const inventory = new AssetUseCases(new SequelizeAssetRepository(database)); + await migrateRelationships(database); + await migrateWorkstations(database); + const assetRepository = new SequelizeAssetRepository(database); + const inventory = new AssetUseCases(assetRepository); + const workstations = new WorkstationUseCases(assetRepository); + const relationships = new AssetRelationshipUseCases(assetRepository); const useCases = new AssetCatalogUseCases(repository); return { register: (app: Parameters[0]) => { registerAssetCatalogRoutes(app, useCases, authenticate); registerAssetRoutes(app, inventory, authenticate); + registerRelationshipRoutes(app, relationships, authenticate); + registerWorkstationRoutes(app, workstations, authenticate); }, }; } diff --git a/modules/assets/src/common/index.ts b/modules/assets/src/common/index.ts index 17e0732..ef98a05 100644 --- a/modules/assets/src/common/index.ts +++ b/modules/assets/src/common/index.ts @@ -28,3 +28,5 @@ export type CatalogResponseDTO = z.infer; export type SaveDefinitionResponseDTO = z.infer; export * from "./inventory"; +export * from "./relationships"; +export * from "./workstations"; diff --git a/modules/assets/src/common/inventory.ts b/modules/assets/src/common/inventory.ts index d034092..ce59a90 100644 --- a/modules/assets/src/common/inventory.ts +++ b/modules/assets/src/common/inventory.ts @@ -7,6 +7,9 @@ import { AssetValueSchema, } from "../api/domain/asset-values"; +import { RelationshipSchema } from "./relationships"; +import { WorkstationSchema } from "./workstations"; + export type { AssetCharacteristic, AssetInput } from "../api/domain/asset-values"; export { AssetInputSchema, AssetSpecificationSchema, AssetStatusSchema, AssetValueSchema }; @@ -27,7 +30,11 @@ export const AssetSchema = z.strictObject({ values: AssetInputSchema, history: z.array(AssetRevisionSchema).min(1), }); -export const InventoryResponseSchema = z.strictObject({ assets: z.array(AssetSchema) }); +export const InventoryResponseSchema = z.strictObject({ + workstations: z.array(WorkstationSchema), + assets: z.array(AssetSchema), + relationships: z.array(RelationshipSchema), +}); export const AssetResponseSchema = z.strictObject({ asset: AssetSchema }); export type AssetDTO = z.infer; export type AssetResponseDTO = z.infer; diff --git a/modules/assets/src/common/relationships.ts b/modules/assets/src/common/relationships.ts new file mode 100644 index 0000000..872a969 --- /dev/null +++ b/modules/assets/src/common/relationships.ts @@ -0,0 +1,25 @@ +import { z } from "zod/v4"; + +import { RelationshipValuesSchema } from "../api/domain/value-objects/asset-relationship-values"; + +export const RelationshipInputSchema = z.strictObject({ + sourceId: z.uuid(), + targetId: z.uuid(), + typeId: z.uuid(), + notes: z.string().max(10000), +}); +export type RelationshipInputDTO = z.infer; +export const RelationshipRequestSchema = z.strictObject({ values: RelationshipInputSchema }); +export const RelationshipSchema = z.strictObject({ + id: z.uuid(), + companyId: z.uuid(), + values: RelationshipValuesSchema, + createdBy: z.uuid(), + createdByName: z.string(), + createdAt: z.iso.datetime(), + endedBy: z.uuid().nullable(), + endedByName: z.string().nullable(), + endedAt: z.iso.datetime().nullable(), +}); +export const RelationshipResponseSchema = z.strictObject({ relationship: RelationshipSchema }); +export type RelationshipDTO = z.infer; diff --git a/modules/assets/src/common/workstations.ts b/modules/assets/src/common/workstations.ts new file mode 100644 index 0000000..7cbb24f --- /dev/null +++ b/modules/assets/src/common/workstations.ts @@ -0,0 +1,23 @@ +import { z } from "zod/v4"; + +import { WorkstationInputSchema } from "../api/domain/workstation-values"; + +export type { WorkstationInput } from "../api/domain/workstation-values"; + +export { WorkstationInputSchema }; +export const SaveWorkstationRequestSchema = z.strictObject({ values: WorkstationInputSchema }); +export const WorkstationRevisionSchema = z.strictObject({ + id: z.uuid(), + values: WorkstationInputSchema, + actorId: z.uuid(), + actorName: z.string().min(1), + recordedAt: z.iso.datetime(), +}); +export const WorkstationSchema = z.strictObject({ + id: z.uuid(), + companyId: z.uuid(), + values: WorkstationInputSchema, + history: z.array(WorkstationRevisionSchema).min(1), +}); +export const WorkstationResponseSchema = z.strictObject({ workstation: WorkstationSchema }); +export type WorkstationDTO = z.infer; diff --git a/modules/assets/src/web/asset-detail.tsx b/modules/assets/src/web/asset-detail.tsx index e18b975..53b12bf 100644 --- a/modules/assets/src/web/asset-detail.tsx +++ b/modules/assets/src/web/asset-detail.tsx @@ -1,18 +1,30 @@ +import { useQuery } from "@tanstack/react-query"; +import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import type { AssetDTO } from "../common/inventory"; import type { InventoryLookups } from "./asset-form"; +import { inventoryApi, inventoryKeys } from "./inventory-api"; export function AssetDetail({ asset, lookups, compact = false, + children, }: { asset: AssetDTO; lookups: InventoryLookups; compact?: boolean; + children?: ReactNode; }) { const { t, i18n } = useTranslation("assets"); + const inventory = useQuery({ + queryKey: inventoryKeys.company(asset.companyId), + queryFn: () => inventoryApi.get(asset.companyId), + }); + const workstation = inventory.data?.workstations.find( + (item) => item.values.active && item.values.assetIds.includes(asset.id) + ); const nameOf = (id: string | null, options: readonly { id: string; name: string }[]) => id ? (options.find((option) => option.id === id)?.name ?? id) : t("noAssignment"); const display = (value: string | number | boolean | undefined) => @@ -41,6 +53,10 @@ export function AssetDetail({
{t("person")}
{nameOf(asset.values.personId, lookups.people)}
+
+
{t("workstationInAsset")}
+
{workstation?.values.name ?? t("noWorkstation")}
+
{asset.specification.fields.map((field) => (
@@ -56,6 +72,7 @@ export function AssetDetail({ {asset.values.notes && (

{asset.values.notes}

)} + {children} {!compact && ( <>

diff --git a/modules/assets/src/web/asset-form.tsx b/modules/assets/src/web/asset-form.tsx index 634d311..6e89a2a 100644 --- a/modules/assets/src/web/asset-form.tsx +++ b/modules/assets/src/web/asset-form.tsx @@ -39,6 +39,7 @@ export function AssetForm({ items, lookups, onSaved, + allowQuickCreate = true, }: { companyId: string; csrfToken: string; @@ -46,6 +47,7 @@ export function AssetForm({ items: readonly CatalogItemDTO[]; lookups: InventoryLookups; onSaved: (id: string) => void; + allowQuickCreate?: boolean; }) { const { t } = useTranslation("assets"); const prefix = useId(); @@ -127,7 +129,7 @@ export function AssetForm({ setCreatingTemplate(true) } } @@ -170,15 +172,19 @@ export function AssetForm({ - lookups.onCreateSite((id) => { - if (mounted.current) { - form.setValue("siteId", id); + action={ + allowQuickCreate + ? { + label: t("newSite"), + onClick: () => + lookups.onCreateSite((id) => { + if (mounted.current) { + form.setValue("siteId", id); + } + }), } - }), - }} + : undefined + } id={`${prefix}-site`} label={t("site")} onValueChange={(id) => form.setValue("siteId", id || null)} @@ -190,15 +196,19 @@ export function AssetForm({ - lookups.onCreatePerson((id) => { - if (mounted.current) { - form.setValue("personId", id); + action={ + allowQuickCreate + ? { + label: t("newPerson"), + onClick: () => + lookups.onCreatePerson((id) => { + if (mounted.current) { + form.setValue("personId", id); + } + }), } - }), - }} + : undefined + } id={`${prefix}-person`} label={t("person")} onValueChange={(id) => form.setValue("personId", id || null)} diff --git a/modules/assets/src/web/asset-relationships.tsx b/modules/assets/src/web/asset-relationships.tsx new file mode 100644 index 0000000..191d658 --- /dev/null +++ b/modules/assets/src/web/asset-relationships.tsx @@ -0,0 +1,268 @@ +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 { 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, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useId, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import type { RelationshipDTO, RelationshipInputDTO } from "../common"; + +import { InventoryFeedback } from "./asset-form"; +import { catalogApi, catalogKeys } from "./catalog-api"; +import { CatalogRelationForm } from "./catalog-relation-form"; +import { inventoryApi, inventoryKeys } from "./inventory-api"; + +export function AssetRelationships({ + companyId, + csrfToken, + assetId, +}: { + companyId: string; + csrfToken: string; + assetId: string; +}) { + const { t, i18n } = useTranslation("assets"); + const client = useQueryClient(); + const query = useQuery({ + queryKey: inventoryKeys.company(companyId), + queryFn: () => inventoryApi.get(companyId), + }); + const [adding, setAdding] = useState(false); + const [ending, setEnding] = useState(); + const mutation = useMutation({ + mutationFn: (id: string) => inventoryApi.endRelationship(companyId, id, csrfToken), + onSuccess: async () => { + await client.invalidateQueries({ queryKey: inventoryKeys.company(companyId) }); + setEnding(undefined); + }, + }); + const links = (query.data?.relationships ?? []).filter( + (link) => link.values.sourceId === assetId || link.values.targetId === assetId + ); + const active = links.filter((link) => !link.endedAt); + const ended = links.filter((link) => link.endedAt); + const nameOf = (id: string) => + query.data?.assets.find((asset) => asset.id === id)?.values.name ?? id; + const dateOf = (date: string) => + new Intl.DateTimeFormat(i18n.language, { dateStyle: "medium", timeStyle: "short" }).format( + new Date(date) + ); + const render = (link: RelationshipDTO) => { + const source = link.values.sourceId === assetId; + return ( +
  • +

    + {source ? link.values.name : link.values.inverseName} ·{" "} + {nameOf(source ? link.values.targetId : link.values.sourceId)} +

    + {link.values.notes && ( +

    {link.values.notes}

    + )} +

    + {t("relationCreated", { name: link.createdByName, date: dateOf(link.createdAt) })} +

    + {link.endedAt ? ( +

    + {t("relationEnded", { name: link.endedByName, date: dateOf(link.endedAt) })} +

    + ) : ( + + )} +
  • + ); + }; + return ( +
    +
    +

    {t("relations")}

    + +
    + {active.length ? ( +
      {active.map(render)}
    + ) : ( +

    {t("noRelationships")}

    + )} + {!!ended.length && ( +
    + + {t("endedRelationships")} ({ended.length}) + +
      {ended.map(render)}
    +
    + )} + {adding && ( + + setAdding(false)} + /> + + )} + {ending && ( + { + if (!open) { + setEnding(undefined); + } + }} + open + title={t("endRelationship")} + > +

    + {ending.values.name} · {nameOf(ending.values.sourceId)} →{" "} + {nameOf(ending.values.targetId)} +

    +

    {t("endRelationshipConfirm")}

    + + + + +
    + )} +
    + ); +} +function RelationshipForm({ + companyId, + csrfToken, + assetId, + onSaved, +}: { + companyId: string; + csrfToken: string; + assetId: string; + onSaved: () => void; +}) { + const { t } = useTranslation("assets"); + const id = useId(); + const client = useQueryClient(); + const [typeId, setTypeId] = useState(""); + const [targetId, setTargetId] = useState(""); + const [notes, setNotes] = useState(""); + const [quick, setQuick] = useState(false); + const catalog = useQuery({ + queryKey: catalogKeys.scope(companyId), + queryFn: () => catalogApi.get(companyId), + }); + const inventory = useQuery({ + queryKey: inventoryKeys.company(companyId), + queryFn: () => inventoryApi.get(companyId), + }); + const mutation = useMutation({ + mutationFn: (values: RelationshipInputDTO) => inventoryApi.link(companyId, values, csrfToken), + onSuccess: async () => { + await client.invalidateQueries({ queryKey: inventoryKeys.company(companyId) }); + onSaved(); + }, + }); + return ( + <> +
    { + event.preventDefault(); + mutation.mutate({ sourceId: assetId, targetId, typeId, notes }); + }} + > +
    + + setQuick(true) }} + disabled={mutation.isPending || catalog.isPending} + id={`${id}-type`} + label={t("relationshipType")} + onValueChange={setTypeId} + options={(catalog.data?.items ?? []) + .filter((item) => item.values.kind === "relation" && item.effectiveActive) + .map((item) => ({ value: item.id, label: item.values.name }))} + placeholder={t("chooseRelation")} + required + value={typeId} + /> +
    +
    + + asset.id !== assetId) + .map((asset) => ({ value: asset.id, label: asset.values.name }))} + placeholder={t("chooseAsset")} + required + value={targetId} + /> +
    +
    + +