Puestos de trabajo

This commit is contained in:
David Arranz 2026-10-04 21:04:14 +02:00
parent 2a39be97f4
commit 867d5ec9c1
64 changed files with 3117 additions and 55 deletions

View File

@ -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(

View File

@ -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(

View File

@ -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<Response>;
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);
}

View File

@ -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<Response>;
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
);
}

View File

@ -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);
}

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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:*",

View File

@ -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<string, string>
): 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,
};
}

View File

@ -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,
});
}

View File

@ -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<string, string>;
}>;
export interface IAssetRepository {
saveWorkstation(
companyId: UniqueID,
action: (context: InventoryContext) => Result<Workstation, AssetCatalogError>
): Promise<
Result<{ workstation: Workstation; actorNames: ReadonlyMap<string, string> }, AssetCatalogError>
>;
get(companyId: UniqueID): Promise<Result<InventoryContext, AssetCatalogError>>;
saveRelationship(
companyId: UniqueID,
action: (context: InventoryContext) => Result<AssetRelationship, AssetCatalogError>
): Promise<
Result<
{ relationship: AssetRelationship; actorNames: ReadonlyMap<string, string> },
AssetCatalogError
>
>;
save(
companyId: UniqueID,
action: (context: InventoryContext) => Result<Asset, AssetCatalogError>

View File

@ -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;
}

View File

@ -0,0 +1,19 @@
import type { WorkstationDTO } from "../../common/workstations";
import type { Workstation } from "../domain/workstation.aggregate";
export function buildWorkstationResponse(
item: Workstation,
names: ReadonlyMap<string, string>
): 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,
})),
};
}

View File

@ -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);
});
}
}

View File

@ -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;
}

View File

@ -24,7 +24,7 @@ function changed(result: ReturnType<AssetCatalog["save"]>) {
}
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);

View File

@ -76,6 +76,13 @@ export class AssetDefinition extends DomainEntity<DefinitionProps> {
) {
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);
}

View File

@ -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<typeof AssetDefinitionSchema>;
export type AssetFieldType = z.infer<typeof FieldTypeSchema>;

View File

@ -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<UniqueID>;
endedAt: Maybe<string>;
}>;
export class AssetRelationship extends AggregateRoot<RelationshipProps> {
private constructor(props: RelationshipProps, id?: UniqueID) {
super(Object.freeze({ ...props }), id);
}
static create(
companyId: UniqueID,
values: AssetRelationshipValues,
actor: UniqueID,
now: string
): Result<AssetRelationship, AssetCatalogError> {
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<AssetRelationship, AssetCatalogError> {
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
)
);
}
}

View File

@ -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);
});

View File

@ -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<AssetDefinitionSpec, { kind: "relation" }>, 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" &&

View File

@ -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<typeof RelationshipValuesSchema>;
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<AssetRelationshipValues, AssetCatalogError> {
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(":");
}
}

View File

@ -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<UniqueID>,
public readonly personId: Maybe<UniqueID>,
public readonly assetIds: readonly UniqueID[]
) {
Object.freeze(this);
}
static create(input: WorkstationInput): Result<WorkstationValues, AssetCatalogError> {
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<Maybe<UniqueID>, 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"));
}

View File

@ -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<WorkstationRevisionProps> {
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;
}
}

View File

@ -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<typeof WorkstationInputSchema>;

View File

@ -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<WorkstationProps> {
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<Workstation, AssetCatalogError> {
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<Workstation, AssetCatalogError> {
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
)
);
}
}

View File

@ -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()
);
});

View File

@ -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);
}

View File

@ -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)
),

View File

@ -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);
}

View File

@ -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,
};
}
}

View File

@ -0,0 +1,77 @@
import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize";
import { DataTypes, Model, QueryTypes } from "sequelize";
export class RelationshipModel extends Model<
InferAttributes<RelationshipModel>,
InferCreationAttributes<RelationshipModel>
> {
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,
});
}

View File

@ -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;
});
}

View File

@ -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<IAssetRepository["saveWorkstation"]>[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<IAssetRepository["saveRelationship"]>[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));
}

View File

@ -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,
})),
};
}
}

View File

@ -0,0 +1,119 @@
import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize";
import { DataTypes, Model, QueryTypes } from "sequelize";
export class WorkstationModel extends Model<
InferAttributes<WorkstationModel>,
InferCreationAttributes<WorkstationModel>
> {
declare id: string;
declare companyId: string;
declare siteId: string | null;
declare personId: string | null;
declare values: unknown;
}
export class WorkstationRevisionModel extends Model<
InferAttributes<WorkstationRevisionModel>,
InferCreationAttributes<WorkstationRevisionModel>
> {
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<WorkstationAssetModel>,
InferCreationAttributes<WorkstationAssetModel>
> {
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,
});
}

View File

@ -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<typeof registerAssetCatalogRoutes>[0]) => {
registerAssetCatalogRoutes(app, useCases, authenticate);
registerAssetRoutes(app, inventory, authenticate);
registerRelationshipRoutes(app, relationships, authenticate);
registerWorkstationRoutes(app, workstations, authenticate);
},
};
}

View File

@ -28,3 +28,5 @@ export type CatalogResponseDTO = z.infer<typeof CatalogResponseSchema>;
export type SaveDefinitionResponseDTO = z.infer<typeof SaveDefinitionResponseSchema>;
export * from "./inventory";
export * from "./relationships";
export * from "./workstations";

View File

@ -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<typeof AssetSchema>;
export type AssetResponseDTO = z.infer<typeof AssetResponseSchema>;

View File

@ -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<typeof RelationshipInputSchema>;
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<typeof RelationshipSchema>;

View File

@ -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<typeof WorkstationSchema>;

View File

@ -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({
<dt className="text-sm text-muted-foreground">{t("person")}</dt>
<dd>{nameOf(asset.values.personId, lookups.people)}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">{t("workstationInAsset")}</dt>
<dd>{workstation?.values.name ?? t("noWorkstation")}</dd>
</div>
{asset.specification.fields.map((field) => (
<div key={field.fieldId}>
<dt className="text-sm text-muted-foreground">
@ -56,6 +72,7 @@ export function AssetDetail({
{asset.values.notes && (
<p className="whitespace-pre-wrap break-words">{asset.values.notes}</p>
)}
{children}
{!compact && (
<>
<h3 className="font-medium">

View File

@ -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({
<Label htmlFor={`${prefix}-template`}>{t("template")}</Label>
<SelectWithAction
action={
asset
asset || !allowQuickCreate
? undefined
: { label: t("newTemplate"), onClick: () => setCreatingTemplate(true) }
}
@ -170,15 +172,19 @@ export function AssetForm({
<Label htmlFor={`${prefix}-site`}>{t("site")}</Label>
<SelectWithAction
{...selectProps}
action={{
label: t("newSite"),
onClick: () =>
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({
<Label htmlFor={`${prefix}-person`}>{t("person")}</Label>
<SelectWithAction
{...selectProps}
action={{
label: t("newPerson"),
onClick: () =>
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)}

View File

@ -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<RelationshipDTO>();
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 (
<li className="space-y-2 rounded-md border p-3 text-sm" key={link.id}>
<p className="font-medium">
{source ? link.values.name : link.values.inverseName} ·{" "}
{nameOf(source ? link.values.targetId : link.values.sourceId)}
</p>
{link.values.notes && (
<p className="whitespace-pre-wrap break-words">{link.values.notes}</p>
)}
<p className="text-xs text-muted-foreground">
{t("relationCreated", { name: link.createdByName, date: dateOf(link.createdAt) })}
</p>
{link.endedAt ? (
<p className="text-xs text-muted-foreground">
{t("relationEnded", { name: link.endedByName, date: dateOf(link.endedAt) })}
</p>
) : (
<Button
onClick={() => {
mutation.reset();
setEnding(link);
}}
size="sm"
variant="outline"
>
{t("endRelationship")}
</Button>
)}
</li>
);
};
return (
<section aria-label={t("relations")} className="space-y-3">
<header className="flex flex-wrap items-center justify-between gap-2">
<h3 className="font-medium">{t("relations")}</h3>
<Button onClick={() => setAdding(true)} size="sm" variant="outline">
{t("linkAsset")}
</Button>
</header>
{active.length ? (
<ul className="space-y-3">{active.map(render)}</ul>
) : (
<p className="text-sm text-muted-foreground">{t("noRelationships")}</p>
)}
{!!ended.length && (
<details>
<summary className="cursor-pointer text-sm">
{t("endedRelationships")} ({ended.length})
</summary>
<ul className="mt-3 space-y-3">{ended.map(render)}</ul>
</details>
)}
{adding && (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={setAdding}
open
title={t("linkAsset")}
>
<RelationshipForm
assetId={assetId}
companyId={companyId}
csrfToken={csrfToken}
onSaved={() => setAdding(false)}
/>
</FormDialog>
)}
{ending && (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
setEnding(undefined);
}
}}
open
title={t("endRelationship")}
>
<p className="text-sm">
{ending.values.name} · {nameOf(ending.values.sourceId)} →{" "}
{nameOf(ending.values.targetId)}
</p>
<p className="text-sm text-muted-foreground">{t("endRelationshipConfirm")}</p>
<InventoryFeedback error={mutation.error} />
<FormActions>
<Button disabled={mutation.isPending} onClick={() => mutation.mutate(ending.id)}>
{t("endRelationship")}
</Button>
</FormActions>
</FormDialog>
)}
</section>
);
}
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 (
<>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
mutation.mutate({ sourceId: assetId, targetId, typeId, notes });
}}
>
<div className="space-y-2">
<Label htmlFor={`${id}-type`}>{t("relationshipType")}</Label>
<SelectWithAction
action={{ label: t("newRelation"), onClick: () => 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}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-target`}>{t("targetAsset")}</Label>
<SelectWithAction
disabled={mutation.isPending || inventory.isPending}
id={`${id}-target`}
label={t("targetAsset")}
onValueChange={setTargetId}
options={(inventory.data?.assets ?? [])
.filter((asset) => asset.id !== assetId)
.map((asset) => ({ value: asset.id, label: asset.values.name }))}
placeholder={t("chooseAsset")}
required
value={targetId}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-notes`}>{t("notes")}</Label>
<Textarea
id={`${id}-notes`}
maxLength={10000}
onChange={(event) => setNotes(event.target.value)}
value={notes}
/>
</div>
<InventoryFeedback error={mutation.error ?? catalog.error ?? inventory.error} />
<ContextHelp
description={t("relationshipHelp")}
examples={t("relationshipExample")}
examplesLabel={t("examplesLabel")}
guidance={t("relationshipGuidance")}
title={t("helpTitle")}
/>
<FormActions>
<Button disabled={!(typeId && targetId) || mutation.isPending} type="submit">
{t(mutation.isPending ? "saving" : "save")}
</Button>
</FormActions>
</form>
{quick && (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={setQuick}
open
title={t("newRelation")}
>
<p className="text-sm text-muted-foreground">{t("quickRelationNotice")}</p>
<CatalogRelationForm
csrfToken={csrfToken}
onSaved={(savedId) => {
setTypeId(savedId);
setQuick(false);
}}
scope={companyId}
/>
</FormDialog>
)}
</>
);
}

View File

@ -53,6 +53,11 @@ export function CatalogDefinitionDialog({
</div>
)}
</dl>
) : values.kind === "relation" ? (
<p>
{t("inverseName")}: {values.inverseName} ·{" "}
{t(values.symmetric ? "symmetric" : "directional")}
</p>
) : (
<ul className="divide-y rounded-md border">
{values.fields.map((binding) => {

View File

@ -1,6 +1,12 @@
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
import { useTranslation } from "react-i18next";
export function CatalogHelp({ kind, scope }: { kind: "field" | "template"; scope: string }) {
export function CatalogHelp({
kind,
scope,
}: {
kind: "field" | "template" | "relation";
scope: string;
}) {
const { t } = useTranslation("assets");
return (
<ContextHelp

View File

@ -20,14 +20,19 @@ import type { CatalogItemDTO } from "../common";
import { catalogApi, catalogKeys } from "./catalog-api";
import { CatalogDefinitionDialog } from "./catalog-definition-dialog";
import { CatalogError, CatalogFieldForm } from "./catalog-form";
import { CatalogRelationForm } from "./catalog-relation-form";
import { CatalogTemplateForm } from "./catalog-template-form";
type Editor = { kind: "field" | "template"; item?: CatalogItemDTO; copy?: CatalogItemDTO };
type Editor = {
kind: "field" | "template" | "relation";
item?: CatalogItemDTO;
copy?: CatalogItemDTO;
};
export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToken: string }) {
const { t } = useTranslation("assets");
const searchId = useId();
const client = useQueryClient();
const [tab, setTab] = useState<"field" | "template">("template");
const [tab, setTab] = useState<"field" | "template" | "relation">("template");
const [search, setSearch] = useState("");
const [view, setView] = useState<CatalogItemDTO>();
const [editor, setEditor] = useState<Editor>();
@ -61,11 +66,11 @@ export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToke
</p>
</div>
<Button disabled={!query.data} onClick={() => setEditor({ kind: tab })}>
{t(tab === "field" ? "newField" : "newTemplate")}
{t({ field: "newField", template: "newTemplate", relation: "newRelation" }[tab])}
</Button>
</div>
<nav aria-label={t("title")} className="flex gap-2">
{(["template", "field"] as const).map((kind) => (
{(["template", "field", "relation"] as const).map((kind) => (
<Button
aria-pressed={tab === kind}
key={kind}
@ -75,7 +80,7 @@ export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToke
}}
variant={tab === kind ? "secondary" : "outline"}
>
{t(kind === "field" ? "fields" : "templates")}
{t({ field: "fields", template: "templates", relation: "relations" }[kind])}
</Button>
))}
</nav>
@ -109,7 +114,9 @@ export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToke
<TableRow>
<TableHead>{t("name")}</TableHead>
<TableHead>{t("origin")}</TableHead>
<TableHead>{t(tab === "field" ? "type" : "fields")}</TableHead>
<TableHead>
{t({ field: "type", template: "fields", relation: "inverseName" }[tab])}
</TableHead>
<TableHead>{t("active")}</TableHead>
<TableHead>{t("actions")}</TableHead>
</TableRow>
@ -156,6 +163,13 @@ export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToke
onSaved={() => setEditor(undefined)}
scope={scope}
/>
) : editor.kind === "relation" ? (
<CatalogRelationForm
csrfToken={csrfToken}
item={editor.item}
onSaved={() => setEditor(undefined)}
scope={scope}
/>
) : (
<CatalogTemplateForm
copy={editor.copy}
@ -216,7 +230,9 @@ function CatalogRow({
<TableCell>
{values.kind === "field"
? t(values.type)
: t("fieldCount", { count: values.fields.length })}
: values.kind === "relation"
? values.inverseName
: t("fieldCount", { count: values.fields.length })}
</TableCell>
<TableCell>
{t(item.effectiveActive ? "active" : "inactive")}
@ -245,6 +261,9 @@ function editorTitle(editor: Editor) {
if (editor.kind === "field") {
return editor.item ? "editField" : "newField";
}
if (editor.kind === "relation") {
return editor.item ? "editRelation" : "newRelation";
}
return editor.item ? "editTemplate" : "newTemplate";
}

View File

@ -0,0 +1,97 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@repo/shadcn-ui/components/button";
import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
import { Input } from "@repo/shadcn-ui/components/input";
import { Label } from "@repo/shadcn-ui/components/label";
import { Textarea } from "@repo/shadcn-ui/components/textarea";
import { useId } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { z } from "zod/v4";
import { AssetDefinitionSchema, type CatalogItemDTO } from "../common";
import { CatalogError, type CatalogSaved, useCatalogSave } from "./catalog-form";
import { CatalogHelp } from "./catalog-help";
const schema = AssetDefinitionSchema.options[2];
export function CatalogRelationForm({
scope,
csrfToken,
item,
onSaved,
}: {
scope: string;
csrfToken: string;
item?: CatalogItemDTO | undefined;
onSaved: CatalogSaved;
}) {
const { t } = useTranslation("assets");
const id = useId();
const current = item?.values.kind === "relation" ? item.values : undefined;
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: current ?? {
kind: "relation",
name: "",
inverseName: "",
description: "",
symmetric: false,
},
});
const mutation = useCatalogSave(scope, csrfToken, onSaved, item?.id);
const symmetric = form.watch("symmetric");
return (
<form className="space-y-4" onSubmit={form.handleSubmit((values) => mutation.mutate(values))}>
<div className="space-y-2">
<Label htmlFor={id}>{t("name")}</Label>
<Input
id={id}
maxLength={100}
required
{...form.register("name")}
onChange={(event) => {
form.setValue("name", event.target.value);
if (symmetric) {
form.setValue("inverseName", event.target.value);
}
}}
/>
</div>
<label className="flex items-center gap-2 text-sm">
<input
checked={symmetric}
disabled={!!item}
onChange={(event) => {
form.setValue("symmetric", event.target.checked);
if (event.target.checked) {
form.setValue("inverseName", form.getValues("name"));
}
}}
type="checkbox"
/>
{t("symmetric")}
</label>
{!symmetric && (
<div className="space-y-2">
<Label htmlFor={`${id}-inverse`}>{t("inverseName")}</Label>
<Input id={`${id}-inverse`} maxLength={100} required {...form.register("inverseName")} />
</div>
)}
<div className="space-y-2">
<Label htmlFor={`${id}-description`}>{t("description")}</Label>
<Textarea id={`${id}-description`} maxLength={1000} {...form.register("description")} />
</div>
<CatalogError
error={mutation.error}
invalid={Object.keys(form.formState.errors).length > 0}
/>
<CatalogHelp kind="relation" scope={scope} />
<FormActions>
<Button disabled={mutation.isPending} type="submit">
{t(mutation.isPending ? "saving" : "save")}
</Button>
</FormActions>
</form>
);
}

View File

@ -1,10 +1,14 @@
import { inventoryEnglish, inventorySpanish } from "./inventory-i18n";
import { relationshipsEnglish, relationshipsSpanish } from "./relationship-i18n";
import { workstationsEnglish, workstationsSpanish } from "./workstation-i18n";
export const assetsSpanish = {
...inventorySpanish,
...workstationsSpanish,
...relationshipsSpanish,
yes: "Sí",
no: "No",
title: "Catálogo de activos",
companyTitle: "Plantillas de activos",
companyTitle: "Catálogo de activos",
introduction: "Define las características y plantillas que utilizarás al registrar activos.",
companyIntroduction: "Utiliza las definiciones globales y añade las que necesite esta compañía.",
fields: "Campos",
@ -84,6 +88,7 @@ export const assetsSpanish = {
"Esta plantilla contiene campos inactivos. Puedes activarlos en el catálogo antes de utilizarla.",
quickNotice: "El campo se guardará aunque después canceles la plantilla.",
errors: {
RELATION_DIRECTION_FIXED: "La dirección de una relación no se puede cambiar.",
INVALID_REQUEST: "Revisa los datos del formulario.",
INVALID_CHOICES:
"Una lista necesita valores únicos y no vacíos. Los demás tipos no admiten valores de lista.",
@ -102,10 +107,12 @@ export const assetsSpanish = {
};
export const assetsEnglish = {
...inventoryEnglish,
...workstationsEnglish,
...relationshipsEnglish,
yes: "Yes",
no: "No",
title: "Asset catalog",
companyTitle: "Asset templates",
companyTitle: "Asset catalog",
introduction: "Define the characteristics and templates used when registering assets.",
companyIntroduction: "Use global definitions and add those needed by this company.",
fields: "Fields",
@ -183,6 +190,7 @@ export const assetsEnglish = {
"This template contains inactive fields. You can activate them in the catalog before using it.",
quickNotice: "The field remains saved if you later cancel the template.",
errors: {
RELATION_DIRECTION_FIXED: "Relationship direction cannot be changed.",
INVALID_REQUEST: "Check the form data.",
INVALID_CHOICES:
"A list needs unique non-empty values. Other data types do not accept list values.",

View File

@ -3,3 +3,4 @@ export { AssetCatalogPage } from "./catalog-page";
export { assetsEnglish, assetsSpanish } from "./i18n";
export { inventoryKeys } from "./inventory-api";
export { AssetInventoryPage } from "./inventory-page";
export { WorkstationsPage } from "./workstations-page";

View File

@ -2,6 +2,8 @@ import { z } from "zod/v4";
import type { AssetInput } from "../common/inventory";
import { AssetResponseSchema, InventoryResponseSchema } from "../common/inventory";
import { type RelationshipInputDTO, RelationshipResponseSchema } from "../common/relationships";
import { type WorkstationInput, WorkstationResponseSchema } from "../common/workstations";
export const inventoryKeys = { company: (id: string) => ["inventory", id] };
export class InventoryApiError extends Error {
constructor(public readonly code: string) {
@ -26,6 +28,21 @@ async function request(path: string, body?: unknown, csrfToken?: string) {
return data;
}
export const inventoryApi = {
async saveWorkstation(companyId: string, values: WorkstationInput, token: string, id?: string) {
return WorkstationResponseSchema.parse(
await request(`${companyId}/workstations${id ? `/${id}/update` : ""}`, { values }, token)
);
},
async link(companyId: string, values: RelationshipInputDTO, token: string) {
return RelationshipResponseSchema.parse(
await request(`${companyId}/relationships`, { values }, token)
);
},
async endRelationship(companyId: string, id: string, token: string) {
return RelationshipResponseSchema.parse(
await request(`${companyId}/relationships/${id}/end`, {}, token)
);
},
async get(companyId: string) {
return InventoryResponseSchema.parse(await request(companyId));
},

View File

@ -6,7 +6,7 @@ export const inventorySpanish = {
inventory: "Activos",
chooseValue: "Selecciona una opción",
relatedNotice:
"El elemento se guarda por separado y se conserva aunque después canceles el alta del activo.",
"El elemento se guarda por separado y se conserva aunque después canceles el formulario principal.",
newAsset: "Nuevo activo",
editAsset: "Editar activo",
viewAsset: "Ficha e historial",
@ -38,6 +38,14 @@ export const inventorySpanish = {
"El activo pertenece a esta compañía. La sede y la persona son opcionales y deben pertenecer a ella.",
saveAssetError: "No se pudo guardar el activo.",
inventoryErrors: {
SELF_RELATIONSHIP: "Un activo no puede relacionarse consigo mismo.",
RELATIONSHIP_EXISTS: "Ya existe esa relación entre estos activos.",
RELATIONSHIP_NOT_FOUND: "La relación no existe en esta compañía.",
RELATION_TYPE_UNAVAILABLE: "El tipo de relación debe estar activo en esta compañía.",
INVALID_MEMBERS: "Selecciona activos distintos y válidos.",
WORKSTATION_NOT_FOUND: "El puesto no existe en esta compañía.",
ASSET_IN_WORKSTATION:
"Un activo ya pertenece a otro puesto activo. Retíralo de allí antes de asignarlo.",
INVALID_REQUEST: "Revisa los datos del formulario.",
INVALID_CHARACTERISTICS: "Alguna característica no corresponde a su campo o tipo.",
REQUIRED_CHARACTERISTICS: "Completa las características obligatorias de la plantilla.",
@ -61,7 +69,7 @@ export const inventoryEnglish = {
inventory: "Assets",
chooseValue: "Choose an option",
relatedNotice:
"This item is saved separately and remains available if you later cancel the asset.",
"This item is saved separately and remains available if you later cancel the main form.",
newAsset: "New asset",
editAsset: "Edit asset",
viewAsset: "Details and history",
@ -93,6 +101,14 @@ export const inventoryEnglish = {
"The asset belongs to this company. Site and person are optional and must belong to it.",
saveAssetError: "The asset could not be saved.",
inventoryErrors: {
SELF_RELATIONSHIP: "An asset cannot link to itself.",
RELATIONSHIP_EXISTS: "This relationship already exists between these assets.",
RELATIONSHIP_NOT_FOUND: "Relationship not found in this company.",
RELATION_TYPE_UNAVAILABLE: "The relationship type must be active for this company.",
INVALID_MEMBERS: "Select distinct valid assets.",
WORKSTATION_NOT_FOUND: "Workstation not found in this company.",
ASSET_IN_WORKSTATION:
"An asset belongs to another active workstation. Remove it there before assigning it.",
INVALID_REQUEST: "Check the form data.",
INVALID_CHARACTERISTICS: "A characteristic does not match its field or type.",
REQUIRED_CHARACTERISTICS: "Complete the template's required characteristics.",

View File

@ -11,6 +11,7 @@ import type { AssetDTO } from "../common/inventory";
import { AssetDetail } from "./asset-detail";
import type { InventoryLookups } from "./asset-form";
import { AssetForm, InventoryFeedback } from "./asset-form";
import { AssetRelationships } from "./asset-relationships";
import { catalogApi, catalogKeys } from "./catalog-api";
import { CatalogError } from "./catalog-form";
import { inventoryApi, inventoryKeys } from "./inventory-api";
@ -159,6 +160,8 @@ export function AssetInventoryPage({
<AssetSelectionPanel
asset={selected}
canEdit={catalog.isSuccess}
companyId={companyId}
csrfToken={csrfToken}
lookups={lookups}
onClose={() => setDetailOpen(false)}
onEdit={setEditor}
@ -204,7 +207,9 @@ export function AssetInventoryPage({
title={viewed.values.name}
wide
>
<AssetDetail asset={viewed} lookups={lookups} />
<AssetDetail asset={viewed} lookups={lookups}>
<AssetRelationships assetId={viewed.id} companyId={companyId} csrfToken={csrfToken} />
</AssetDetail>
</FormDialog>
)}
</div>
@ -216,6 +221,8 @@ function selectedRowClass(id: string, selectedId: string | undefined, detailOpen
}
function AssetSelectionPanel({
companyId,
csrfToken,
asset,
open,
lookups,
@ -224,6 +231,8 @@ function AssetSelectionPanel({
onView,
onEdit,
}: {
companyId: string;
csrfToken: string;
asset: AssetDTO | undefined;
open: boolean;
lookups: InventoryLookups;
@ -244,7 +253,9 @@ function AssetSelectionPanel({
×
</Button>
</header>
<AssetDetail asset={asset} compact lookups={lookups} />
<AssetDetail asset={asset} compact lookups={lookups}>
<AssetRelationships assetId={asset.id} companyId={companyId} csrfToken={csrfToken} />
</AssetDetail>
<div className="flex flex-wrap gap-2">
<Button onClick={() => onView(asset.id)} variant="outline">
{t("viewAsset")}

View File

@ -12,13 +12,18 @@ import { I18nextProvider } from "react-i18next";
import { buildCatalogResponse } from "../api/application/asset-catalog-response.builder";
import { AssetCatalog } from "../api/domain/asset-catalog.aggregate";
import { seedAssetCatalog } from "../api/domain/asset-catalog-seed";
import type { RelationshipDTO } from "../common";
import { AssetForm } from "./asset-form";
import { AssetRelationships } from "./asset-relationships";
import { catalogKeys } from "./catalog-api";
import { CatalogRelationForm } from "./catalog-relation-form";
import { CatalogTemplateForm } from "./catalog-template-form";
import { assetsEnglish, assetsSpanish } from "./i18n";
import { inventoryKeys } from "./inventory-api";
import { AssetInventoryPage } from "./inventory-page";
import { WorkstationForm } from "./workstation-form";
import { WorkstationsPage } from "./workstations-page";
const companyId = UniqueID.generateNewID().toPrimitive();
const lookups = {
@ -27,14 +32,18 @@ const lookups = {
onCreateSite: () => undefined,
onCreatePerson: () => undefined,
};
async function render(language: string, node: ReactNode) {
async function render(language: string, node: ReactNode, relationships: RelationshipDTO[] = []) {
const i18n = createInstance();
await i18n.init({
lng: language,
resources: { es: { assets: assetsSpanish }, en: { assets: assetsEnglish } },
});
const client = new QueryClient();
client.setQueryData(inventoryKeys.company(companyId), { assets: [] });
client.setQueryData(inventoryKeys.company(companyId), {
assets: [],
relationships,
workstations: [],
});
client.setQueryData(
catalogKeys.scope(companyId),
buildCatalogResponse(seedAssetCatalog(AssetCatalog.create()), Maybe.none())
@ -94,3 +103,119 @@ test("El activo ofrece altas relacionadas, ayuda inferior y no encadena una terc
);
assert.equal(child.includes('aria-label="Nuevo campo"'), false);
});
test("Las fichas leen ambos sentidos y conservan la auditoría de relaciones retiradas", async () => {
const sourceId = UniqueID.generateNewID().toPrimitive();
const targetId = UniqueID.generateNewID().toPrimitive();
const link: RelationshipDTO = {
id: UniqueID.generateNewID().toPrimitive(),
companyId,
values: {
sourceId,
targetId,
typeId: UniqueID.generateNewID().toPrimitive(),
name: "Alojado en",
inverseName: "Aloja",
symmetric: false,
notes: "Rack 1",
},
createdBy: UniqueID.generateNewID().toPrimitive(),
createdByName: "Técnico",
createdAt: "2026-10-04T10:00:00.000Z",
endedBy: null,
endedByName: null,
endedAt: null,
};
const source = await render(
"es",
<AssetRelationships assetId={sourceId} companyId={companyId} csrfToken="test" />,
[link]
);
const target = await render(
"es",
<AssetRelationships assetId={targetId} companyId={companyId} csrfToken="test" />,
[link]
);
assert.ok(source.includes("Alojado en"));
assert.ok(target.includes("Aloja"));
assert.ok(source.includes("Creada por Técnico"));
assert.ok(source.includes("Retirar relación"));
const ended = await render(
"es",
<AssetRelationships assetId={sourceId} companyId={companyId} csrfToken="test" />,
[
{
...link,
endedBy: link.createdBy,
endedByName: "Técnico",
endedAt: "2026-10-04T11:00:00.000Z",
},
]
);
assert.ok(ended.includes("Relaciones retiradas"));
assert.ok(ended.includes("Retirada por Técnico"));
assert.ok(!ended.includes(">Retirar relación<"));
});
test("El formulario de tipo de relación presenta ayuda justo antes de sus operaciones", async () => {
for (const language of ["es", "en"]) {
const html = await render(
language,
<CatalogRelationForm csrfToken="test" onSaved={() => undefined} scope={companyId} />
);
assert.ok(html.indexOf('name="description"') < html.indexOf("<aside"));
assert.ok(html.indexOf("<aside") < html.indexOf('type="submit"'));
assert.ok(
html.includes(
language === "es" ? "Nombre visto desde el otro activo" : "Name shown from the other asset"
)
);
}
});
test("Puestos de trabajo: listado traducido, alta en cabecera y formulario modal", async () => {
for (const language of ["es", "en"]) {
const html = await render(
language,
<WorkstationsPage companyId={companyId} csrfToken="test" lookups={lookups} />
);
assert.ok(html.includes(language === "es" ? "Nuevo puesto" : "New workstation"));
assert.ok(
html.includes(
language === "es"
? "Esta compañía todavía no tiene puestos de trabajo."
: "This company has no workstations yet."
)
);
assert.equal(html.includes("<form"), false);
}
});
test("Puestos de trabajo: altas rápidas y ayuda inferior; un activo anidado no ofrece terceras altas", async () => {
const html = await render(
"es",
<WorkstationForm
companyId={companyId}
csrfToken="test"
lookups={lookups}
onSaved={() => undefined}
/>
);
for (const label of ["Nueva sede", "Nueva persona", "Nuevo activo"]) {
assert.ok(html.includes(`aria-label="${label}"`));
}
assert.ok(html.indexOf('name="active"') < html.indexOf("<aside"));
assert.ok(html.indexOf("<aside") < html.indexOf('type="submit"'));
const nested = await render(
"es",
<AssetForm
allowQuickCreate={false}
companyId={companyId}
csrfToken="test"
items={[]}
lookups={lookups}
onSaved={() => undefined}
/>
);
for (const label of ["Nueva sede", "Nueva persona", "Nueva plantilla"]) {
assert.ok(!nested.includes(`aria-label="${label}"`));
}
});

View File

@ -0,0 +1,64 @@
export const relationshipsSpanish = {
relations: "Relaciones",
newRelation: "Nuevo tipo de relación",
editRelation: "Editar tipo de relación",
inverseName: "Nombre visto desde el otro activo",
symmetric: "Mismo nombre en ambos sentidos",
directional: "Relación con dirección",
relationDescription:
"Un tipo de relación describe cómo se vinculan dos activos de una misma compañía.",
relationExamples:
"Portátil → Conectado a → Monitor. Máquina virtual → Alojado en → Servidor; desde el servidor se lee «Aloja».",
relationGuidance:
"Puedes desactivar tipos sin perder los vínculos existentes. La dirección queda fija tras el alta. Las relaciones conservan los nombres que tenían al crearse.",
linkAsset: "Relacionar activo",
relationshipType: "Tipo de relación",
targetAsset: "Otro activo",
chooseRelation: "Selecciona una relación",
chooseAsset: "Selecciona un activo",
noRelationships: "Este activo no tiene relaciones vigentes.",
endedRelationships: "Relaciones retiradas",
endRelationship: "Retirar relación",
endRelationshipConfirm: "Se conservará el historial de esta relación.",
relationshipHelp:
"Vincula equipos que trabajan juntos o dependen de otros. Solo se muestran activos de esta compañía.",
relationshipExample: "Por ejemplo: este portátil está conectado a un monitor externo.",
relationshipGuidance:
"Selecciona primero el tipo y después el otro activo. Retirar una relación no elimina los activos.",
quickRelationNotice:
"El tipo se guardará para esta compañía aunque después canceles la relación.",
relationCreated: "Creada por {{name}} · {{date}}",
relationEnded: "Retirada por {{name}} · {{date}}",
};
export const relationshipsEnglish = {
relations: "Relationships",
newRelation: "New relationship type",
editRelation: "Edit relationship type",
inverseName: "Name shown from the other asset",
symmetric: "Same name in both directions",
directional: "Directional relationship",
relationDescription:
"A relationship type describes how two assets in the same company are linked.",
relationExamples:
"Laptop → Connected to → Monitor. Virtual machine → Hosted on → Server; from the server it reads «Hosts».",
relationGuidance:
"Disable types without losing existing links. Direction is fixed after creation. Relationships retain the names they had when created.",
linkAsset: "Link asset",
relationshipType: "Relationship type",
targetAsset: "Other asset",
chooseRelation: "Select a relationship",
chooseAsset: "Select an asset",
noRelationships: "This asset has no current relationships.",
endedRelationships: "Ended relationships",
endRelationship: "End relationship",
endRelationshipConfirm: "The relationship history will be retained.",
relationshipHelp:
"Link devices that work together or depend on others. Only assets in this company are shown.",
relationshipExample: "For example: this laptop is connected to an external monitor.",
relationshipGuidance:
"Select the type and then the other asset. Ending a relationship does not delete assets.",
quickRelationNotice:
"The type is saved for this company even if you later cancel the relationship.",
relationCreated: "Created by {{name}} · {{date}}",
relationEnded: "Ended by {{name}} · {{date}}",
};

View File

@ -0,0 +1,254 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@repo/shadcn-ui/components/button";
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
import { FormActions, FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { Input } from "@repo/shadcn-ui/components/input";
import { Label } from "@repo/shadcn-ui/components/label";
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useId, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { type WorkstationDTO, type WorkstationInput, WorkstationInputSchema } from "../common";
import { AssetForm, InventoryFeedback, type InventoryLookups } from "./asset-form";
import { catalogApi, catalogKeys } from "./catalog-api";
import { inventoryApi, inventoryKeys } from "./inventory-api";
export function WorkstationForm({
companyId,
csrfToken,
lookups,
workstation,
onSaved,
}: {
companyId: string;
csrfToken: string;
lookups: InventoryLookups;
workstation?: WorkstationDTO | undefined;
onSaved: (id: string) => void;
}) {
const { t } = useTranslation("assets");
const prefix = useId();
const client = useQueryClient();
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const form = useForm<WorkstationInput>({
resolver: zodResolver(WorkstationInputSchema),
defaultValues: workstation?.values ?? {
name: "",
active: true,
siteId: null,
personId: null,
assetIds: [],
},
});
const values = form.watch();
const [quick, setQuick] = useState(false);
const inventory = useQuery({
queryKey: inventoryKeys.company(companyId),
queryFn: () => inventoryApi.get(companyId),
});
const catalog = useQuery({
queryKey: catalogKeys.scope(companyId),
queryFn: () => catalogApi.get(companyId),
});
const assets = inventory.data?.assets ?? [];
const stations = inventory.data?.workstations ?? [];
const occupied = new Set(
stations
.filter((item) => item.values.active && item.id !== workstation?.id)
.flatMap((item) => item.values.assetIds)
);
const add = (id: string) => {
if (id && mounted.current && !form.getValues("assetIds").includes(id)) {
form.setValue("assetIds", [...form.getValues("assetIds"), id]);
}
};
const mutation = useMutation({
mutationFn: (input: WorkstationInput) =>
inventoryApi.saveWorkstation(companyId, input, csrfToken, workstation?.id),
onSuccess: async (response) => {
await client.invalidateQueries({ queryKey: inventoryKeys.company(companyId) });
if (mounted.current) {
onSaved(response.workstation.id);
}
},
});
return (
<>
<form className="space-y-5" onSubmit={form.handleSubmit((input) => mutation.mutate(input))}>
<fieldset className="space-y-4" disabled={mutation.isPending}>
<div className="space-y-2">
<Label htmlFor={prefix}>{t("name")}</Label>
<Input id={prefix} maxLength={100} required {...form.register("name")} />
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" {...form.register("active")} />
{t("active")}
</label>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`${prefix}-site`}>{t("site")}</Label>
<SelectWithAction
action={{
label: t("newSite"),
onClick: () =>
lookups.onCreateSite((id) => {
if (mounted.current) {
form.setValue("siteId", id);
}
}),
}}
id={`${prefix}-site`}
label={t("site")}
onValueChange={(id) => form.setValue("siteId", id || null)}
options={lookups.sites.map((item) => ({ value: item.id, label: item.name }))}
placeholder={t("noAssignment")}
value={values.siteId ?? ""}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-person`}>{t("workstationPerson")}</Label>
<SelectWithAction
action={{
label: t("newPerson"),
onClick: () =>
lookups.onCreatePerson((id) => {
if (mounted.current) {
form.setValue("personId", id);
}
}),
}}
id={`${prefix}-person`}
label={t("workstationPerson")}
onValueChange={(id) => form.setValue("personId", id || null)}
options={lookups.people
.filter((item) => item.active || item.id === workstation?.values.personId)
.map((item) => ({ value: item.id, label: item.name }))}
placeholder={t("noAssignment")}
value={values.personId ?? ""}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-assets`}>{t("addWorkstationAsset")}</Label>
<SelectWithAction
action={{
label: t("newAsset"),
onClick: () => setQuick(true),
disabled: !catalog.data,
}}
disabled={inventory.isPending || values.assetIds.length >= 200}
id={`${prefix}-assets`}
label={t("addWorkstationAsset")}
onValueChange={add}
options={assets
.filter((item) => !values.assetIds.includes(item.id))
.map((item) => ({
value: item.id,
label: occupied.has(item.id)
? t("workstationOccupied", { name: item.values.name })
: item.values.name,
disabled: values.active && occupied.has(item.id),
}))}
placeholder={t("chooseAsset")}
value=""
/>
</div>
{values.assetIds.length ? (
<ul className="space-y-2">
{values.assetIds.map((id) => (
<li
className="flex items-center justify-between gap-3 rounded-md border p-3 text-sm"
key={id}
>
<div>
<p>{assets.find((item) => item.id === id)?.values.name ?? id}</p>
{values.active && occupied.has(id) && (
<p className="text-destructive">
{t("inventoryErrors.ASSET_IN_WORKSTATION")}
</p>
)}
</div>
<Button
aria-label={t("removeWorkstationAsset", {
name: assets.find((item) => item.id === id)?.values.name ?? id,
})}
onClick={() =>
form.setValue(
"assetIds",
values.assetIds.filter((item) => item !== id)
)
}
size="sm"
type="button"
variant="outline"
>
×
</Button>
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">{t("noWorkstationAssets")}</p>
)}
</fieldset>
{Object.keys(form.formState.errors).length > 0 && (
<p className="text-sm text-destructive" role="alert">
{t("inventoryErrors.INVALID_REQUEST")}
</p>
)}
<InventoryFeedback error={mutation.error ?? inventory.error} />
<ContextHelp
description={t("workstationDescription")}
examples={t("workstationExamples")}
examplesLabel={t("examplesLabel")}
guidance={t("workstationGuidance")}
scopeNote={t("workstationScope")}
title={t("helpTitle")}
/>
<FormActions>
<Button
disabled={
mutation.isPending ||
!inventory.data ||
(values.active && values.assetIds.some((id) => occupied.has(id)))
}
type="submit"
>
{t(mutation.isPending ? "saving" : "save")}
</Button>
</FormActions>
</form>
{quick && catalog.data && (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={setQuick}
open
title={t("newAsset")}
wide
>
<p className="text-sm text-muted-foreground">{t("relatedNotice")}</p>
<AssetForm
allowQuickCreate={false}
companyId={companyId}
csrfToken={csrfToken}
items={catalog.data.items}
lookups={lookups}
onSaved={(id) => {
add(id);
setQuick(false);
}}
/>
</FormDialog>
)}
</>
);
}

View File

@ -0,0 +1,52 @@
export const workstationsSpanish = {
workstations: "Puestos de trabajo",
newWorkstation: "Nuevo puesto",
editWorkstation: "Editar puesto",
workstationPerson: "Persona del puesto",
workstationHistory: "Historial del puesto",
workstationAssets: "Activos del puesto",
noWorkstations: "Esta compañía todavía no tiene puestos de trabajo.",
noWorkstationAssets: "Este puesto todavía no tiene activos.",
addWorkstationAsset: "Añadir un activo",
workstationOccupied: "{{name}} · ocupado en otro puesto",
removeWorkstationAsset: "Retirar {{name}} del puesto",
workstationCount: "{{count}} activos",
workstationCount_one: "{{count}} activo",
workstationCount_other: "{{count}} activos",
workstationDescription:
"Un puesto reúne los activos que una persona utiliza habitualmente. Puede quedar sin persona o sede y puede crearse sin activos.",
workstationExamples:
"Puesto de Ana: portátil, dos monitores y una impresora. Una persona puede tener varios puestos.",
workstationGuidance:
"Cada activo solo puede estar en un puesto activo. Desactivar conserva la composición y libera sus activos; reactivar comprueba su disponibilidad. El puesto no cambia las asignaciones individuales de los activos.",
workstationScope:
"Todos los elementos deben pertenecer a esta compañía. Los cambios de persona, sede y composición conservan historial.",
workstationInAsset: "Puesto de trabajo",
noWorkstation: "Sin puesto activo",
};
export const workstationsEnglish = {
workstations: "Workstations",
newWorkstation: "New workstation",
editWorkstation: "Edit workstation",
workstationPerson: "Workstation assignee",
workstationHistory: "Workstation history",
workstationAssets: "Workstation assets",
noWorkstations: "This company has no workstations yet.",
noWorkstationAssets: "This workstation has no assets yet.",
addWorkstationAsset: "Add an asset",
workstationOccupied: "{{name}} · used in another workstation",
removeWorkstationAsset: "Remove {{name}} from workstation",
workstationCount: "{{count}} assets",
workstationCount_one: "{{count}} asset",
workstationCount_other: "{{count}} assets",
workstationDescription:
"A workstation groups assets a person uses regularly. Person and site are optional and it can be created without assets.",
workstationExamples:
"Ana's workstation: laptop, two monitors and a printer. One person can have several workstations.",
workstationGuidance:
"Each asset can belong to one active workstation. Deactivation preserves composition and releases its assets; reactivation checks availability. Workstation assignment does not change individual asset assignments.",
workstationScope:
"All elements must belong to this company. Person, site and composition changes retain history.",
workstationInAsset: "Workstation",
noWorkstation: "No active workstation",
};

View File

@ -0,0 +1,167 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { Input } from "@repo/shadcn-ui/components/input";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { WorkstationDTO } from "../common";
import { InventoryFeedback, type InventoryLookups } from "./asset-form";
import { inventoryApi, inventoryKeys } from "./inventory-api";
import { WorkstationForm } from "./workstation-form";
export function WorkstationsPage({
companyId,
csrfToken,
lookups,
}: {
companyId: string;
csrfToken: string;
lookups: InventoryLookups;
}) {
const { t, i18n } = useTranslation("assets");
const [search, setSearch] = useState("");
const [editor, setEditor] = useState<string>();
const [view, setView] = useState<string>();
const query = useQuery({
queryKey: inventoryKeys.company(companyId),
queryFn: () => inventoryApi.get(companyId),
});
const stations = query.data?.workstations ?? [];
const assets = query.data?.assets ?? [];
const edited = stations.find((item) => item.id === editor);
const viewed = stations.find((item) => item.id === view);
const personName = (id: string | null) =>
lookups.people.find((item) => item.id === id)?.name ?? t("noAssignment");
const siteName = (id: string | null) =>
lookups.sites.find((item) => item.id === id)?.name ?? t("noAssignment");
const members = (ids: readonly string[]) =>
ids.map((id) => assets.find((item) => item.id === id)?.values.name ?? id).join(" · ") ||
t("noWorkstationAssets");
const filtered = stations.filter((item) =>
[item.values.name, personName(item.values.personId), siteName(item.values.siteId)].some(
(value) => value.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())
)
);
const summary = (item: WorkstationDTO) => (
<>
<h3 className="font-medium">
{item.values.name} · {t(item.values.active ? "active" : "inactive")}
</h3>
<p className="text-sm text-muted-foreground">
{siteName(item.values.siteId)} · {personName(item.values.personId)} ·{" "}
{t("workstationCount", { count: item.values.assetIds.length })}
</p>
<p className="mt-2 text-sm">{members(item.values.assetIds)}</p>
</>
);
return (
<div className="space-y-5">
<header className="flex flex-wrap items-center justify-between gap-4">
<h2 className="text-xl font-semibold">{t("workstations")}</h2>
<Button disabled={!query.data} onClick={() => setEditor("new")}>
{t("newWorkstation")}
</Button>
</header>
<Input
aria-label={t("search")}
onChange={(event) => setSearch(event.target.value)}
placeholder={t("search")}
value={search}
/>
{query.isPending && <p role="status">{t("loading")}</p>}
<InventoryFeedback error={query.error} />
{query.isError && (
<Button
onClick={() => {
query.refetch().catch(() => undefined);
}}
variant="outline"
>
{t("retry")}
</Button>
)}
{query.data && (
<Card>
<CardContent className="divide-y pt-3">
{!filtered.length && (
<p className="py-4 text-muted-foreground">
{t(stations.length ? "noResults" : "noWorkstations")}
</p>
)}
{filtered.map((item) => (
<div className="flex flex-wrap items-center justify-between gap-4 py-4" key={item.id}>
<div className="min-w-0 break-words">{summary(item)}</div>
<div className="flex gap-2">
<Button onClick={() => setView(item.id)} size="sm" variant="outline">
{t("workstationHistory")}
</Button>
<Button onClick={() => setEditor(item.id)} size="sm" variant="outline">
{t("edit")}
</Button>
</div>
</div>
))}
</CardContent>
</Card>
)}
{editor && (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
setEditor(undefined);
}
}}
open
title={t(edited ? "editWorkstation" : "newWorkstation")}
wide
>
<WorkstationForm
companyId={companyId}
csrfToken={csrfToken}
lookups={lookups}
onSaved={() => setEditor(undefined)}
workstation={edited}
/>
</FormDialog>
)}
{viewed && (
<FormDialog
cancelLabel={t("close")}
onOpenChange={(open) => {
if (!open) {
setView(undefined);
}
}}
open
title={t("workstationHistory")}
wide
>
<ol className="space-y-4">
{[...viewed.history].reverse().map((revision) => (
<li className="space-y-2 rounded-md border p-3 text-sm" key={revision.id}>
<p className="font-medium">
{new Intl.DateTimeFormat(i18n.language, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(revision.recordedAt))}{" "}
· {t("recordedBy", { name: revision.actorName })}
</p>
<p>
{revision.values.name} · {t(revision.values.active ? "active" : "inactive")}
</p>
<p>
{siteName(revision.values.siteId)} · {personName(revision.values.personId)}
</p>
<p>{members(revision.values.assetIds)}</p>
</li>
))}
</ol>
</FormDialog>
)}
</div>
);
}

View File

@ -99,7 +99,14 @@ function SiteSection({ company, csrfToken }: { company: CompanyDTO; csrfToken: s
</div>
);
}
type CompanyTab = "people" | "departments" | "sites" | "access" | "assets" | "inventory";
type CompanyTab =
| "people"
| "departments"
| "sites"
| "access"
| "assets"
| "inventory"
| "workstations";
export function CompanyDetail({
id,
@ -120,7 +127,7 @@ export function CompanyDetail({
const query = useQuery({ queryKey: companyKeys.detail(id), queryFn: () => companyApi.get(id) });
const [editOpen, setEditOpen] = useState(false);
const [tab, setTab] = useState<
"people" | "departments" | "sites" | "access" | "assets" | "inventory"
"people" | "departments" | "sites" | "access" | "assets" | "inventory" | "workstations"
>(embedded ? "inventory" : "people");
const company = query.data?.company;
const layout = embedded
@ -189,6 +196,7 @@ export function CompanyDetail({
{(
[
["inventory", assetTranslation("inventory"), BoxIcon],
["workstations", assetTranslation("workstations"), BriefcaseIcon],
["people", `${t("people")} (${company.people.length})`, UsersIcon],
[
"departments",
@ -242,6 +250,8 @@ function CompanySection({
return (
<AccessWorkspacePage csrfToken={csrfToken} people={company.people} scope={company.id} />
);
case "workstations":
return <CompanyInventory company={company} csrfToken={csrfToken} workstations />;
case "inventory":
return <CompanyInventory company={company} csrfToken={csrfToken} />;
case "assets":

View File

@ -1,4 +1,4 @@
import { AssetInventoryPage } from "@metis/assets/client";
import { AssetInventoryPage, WorkstationsPage } from "@metis/assets/client";
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@ -10,9 +10,11 @@ import { SiteForm } from "./site-form";
export function CompanyInventory({
company,
csrfToken,
workstations = false,
}: {
company: CompanyDTO;
csrfToken: string;
workstations?: boolean;
}) {
const { t } = useTranslation("companies");
const { t: assetTranslation } = useTranslation("assets");
@ -20,9 +22,10 @@ export function CompanyInventory({
kind: "site" | "person";
onCreated: (id: string) => void;
}>();
const Page = workstations ? WorkstationsPage : AssetInventoryPage;
return (
<>
<AssetInventoryPage
<Page
companyId={company.id}
csrfToken={csrfToken}
lookups={{