diff --git a/apps/server/package.json b/apps/server/package.json index 2bd59e7..1140793 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "node --env-file=../../.env --import tsx --watch src/index.ts", "typecheck": "tsc -p tsconfig.json", - "test": "node --import tsx --test src/app.test.ts src/config/env-schema.test.ts src/infrastructure/http/http-error-diagnostic.test.ts && DB_NAME=metis_desk_test NODE_ENV=test node --env-file=../../.env --import tsx --test --test-concurrency=1 src/asset-inventory.integration.test.ts src/sites.integration.test.ts src/identity.test.ts", + "test": "node --import tsx --test src/app.test.ts src/infrastructure.http.test.ts src/config/env-schema.test.ts src/infrastructure/http/http-error-diagnostic.test.ts && DB_NAME=metis_desk_test NODE_ENV=test node --env-file=../../.env --import tsx --test --test-concurrency=1 src/asset-inventory.integration.test.ts src/infrastructure-inventory.integration.test.ts src/sites.integration.test.ts src/identity.test.ts", "build": "tsup --config tsup.config.ts", "start": "node --env-file=../../.env dist/index.js" }, diff --git a/apps/server/src/infrastructure-inventory.integration.test.ts b/apps/server/src/infrastructure-inventory.integration.test.ts new file mode 100644 index 0000000..c7c7eb4 --- /dev/null +++ b/apps/server/src/infrastructure-inventory.integration.test.ts @@ -0,0 +1,158 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { QueryTypes } from "sequelize"; +import { z } from "zod/v4"; + +import { InfrastructureRevision } from "../../../modules/assets/src/api/domain/infrastructure-revision.entity"; +import { InfrastructureValues } from "../../../modules/assets/src/api/domain/value-objects/infrastructure-values.vo"; +import { writeAssetRow } from "../../../modules/assets/src/api/infrastructure/persistence/asset-relational-sql"; +import { + AddressRowSchema, + InfrastructurePersistenceMapper, + InfrastructureRevisionRowSchema, + InfrastructureRoleRowSchema, + InterfaceRowSchema, +} from "../../../modules/assets/src/api/infrastructure/persistence/infrastructure-persistence.mapper"; +import { removeAssetInfrastructure } from "../../../modules/assets/src/api/infrastructure/persistence/remove-asset-infrastructure"; +import { writeInfrastructureRevision } from "../../../modules/assets/src/api/infrastructure/persistence/write-infrastructure-revision"; +import { UniqueID } from "../../../packages/rdx-ddd/src/index"; + +import { createDatabase, readDatabaseEnvironment } from "./config/database"; + +const TABLES = { + asset_infrastructure: "id CHAR(36) PRIMARY KEY,companyId CHAR(36),assetId CHAR(36)", + asset_infrastructure_revisions: + "id CHAR(36) PRIMARY KEY,infrastructureId CHAR(36),actorId CHAR(36),recordedAt VARCHAR(30),position INT", + asset_infrastructure_roles: "revisionId CHAR(36),role VARCHAR(30),PRIMARY KEY(revisionId,role)", + asset_interface_snapshots: + "revisionId CHAR(36),id CHAR(36),name VARCHAR(100),kind VARCHAR(12),mac VARCHAR(17),notes TEXT,position INT,PRIMARY KEY(revisionId,id)", + asset_interface_address_snapshots: + "revisionId CHAR(36),id CHAR(36),interfaceId CHAR(36),address VARCHAR(45),position INT,PRIMARY KEY(revisionId,id)", +}; + +test("MariaDB: interfaces y direcciones conservan identidad e historial al cambiar la IP", async () => { + const environment = readDatabaseEnvironment(process.env); + + assert.equal(environment.NODE_ENV, "test"); + assert.equal(environment.DB_NAME, "metis_desk_test"); + const database = createDatabase(environment); + + try { + await database.transaction(async (transaction) => { + const createdTables: string[] = []; + + try { + for (const [table, columns] of Object.entries(TABLES)) { + await database.query(`CREATE TEMPORARY TABLE ${table} (${columns})`, { transaction }); + createdTables.push(table); + } + const root = { + id: UniqueID.generateNewID().toPrimitive(), + companyId: UniqueID.generateNewID().toPrimitive(), + assetId: UniqueID.generateNewID().toPrimitive(), + }; + + await writeAssetRow(database, "asset_infrastructure", root, transaction); + const actor = UniqueID.generateNewID(); + const values = InfrastructureValues.create({ + roles: ["shared_peripheral"], + interfaces: [ + { + name: "Ethernet", + kind: "ethernet", + mac: "aa-bb-cc-dd-ee-ff", + notes: "Conservar", + addresses: [{ address: "192.168.5.201" }, { address: "2001:db8::1" }], + }, + { name: "Wi-Fi", kind: "wifi", mac: "", notes: "", addresses: [] }, + ], + }); + + assert.ok(values.isSuccess); + const first = InfrastructureRevision.create({ + values: values.data, + actorId: actor, + recordedAt: "2026-10-17T10:00:00.000Z", + }); + + await writeInfrastructureRevision(database, root.id, first, 0, transaction); + const draft = values.data.toPrimitive(); + const iface = draft.interfaces[0]; + + assert.ok(iface); + const address = iface.addresses[0]; + + assert.ok(address); + address.address = "192.168.5.202"; + const changed = InfrastructureValues.create(draft, values.data); + + assert.ok(changed.isSuccess); + const second = InfrastructureRevision.create({ + values: changed.data, + actorId: actor, + recordedAt: "2026-10-17T11:00:00.000Z", + }); + + await writeInfrastructureRevision(database, root.id, second, 1, transaction); + + const read = (table: string) => + database.query(`SELECT * FROM ${table}`, { type: QueryTypes.SELECT, transaction }); + + const revisions = z + .array(InfrastructureRevisionRowSchema) + .parse(await read("asset_infrastructure_revisions")); + const roles = z + .array(InfrastructureRoleRowSchema) + .parse(await read("asset_infrastructure_roles")); + const interfaces = z + .array(InterfaceRowSchema) + .parse(await read("asset_interface_snapshots")); + const addresses = z + .array(AddressRowSchema) + .parse(await read("asset_interface_address_snapshots")); + const restored = new InfrastructurePersistenceMapper().toDomain( + root, + revisions, + roles, + interfaces, + addresses + ); + + assert.deepEqual(restored.values.toPrimitive(), changed.data.toPrimitive()); + assert.deepEqual(restored.history[0]?.values.toPrimitive(), values.data.toPrimitive()); + assert.equal(restored.history.length, 2); + assert.equal(restored.values.toPrimitive().interfaces[0]?.id, iface.id); + assert.equal(restored.values.toPrimitive().interfaces[0]?.addresses[0]?.id, address.id); + const corrupt = revisions.map((row, index) => ({ ...row, position: index + 2 })); + + assert.throws( + () => + new InfrastructurePersistenceMapper().toDomain( + root, + corrupt, + roles, + interfaces, + addresses + ), + /Secuencia/ + ); + await removeAssetInfrastructure(database, root.companyId, root.assetId, transaction); + for (const table of Object.keys(TABLES)) { + const remaining = await database.query(`SELECT 1 FROM ${table}`, { + type: QueryTypes.SELECT, + transaction, + }); + + assert.equal(remaining.length, 0); + } + } finally { + for (const table of createdTables.reverse()) { + await database.query(`DROP TEMPORARY TABLE ${table}`, { transaction }); + } + } + }); + } finally { + await database.close(); + } +}); diff --git a/apps/server/src/infrastructure.http.test.ts b/apps/server/src/infrastructure.http.test.ts new file mode 100644 index 0000000..f75161a --- /dev/null +++ b/apps/server/src/infrastructure.http.test.ts @@ -0,0 +1,159 @@ +import assert from "node:assert/strict"; +import { once } from "node:events"; +import { test } from "node:test"; + +import express from "express"; + +import type { IInfrastructureRepository } from "../../../modules/assets/src/api/application/infrastructure.repository"; +import { InfrastructureUseCases } from "../../../modules/assets/src/api/application/infrastructure.use-cases"; +import { AssetCatalogError } from "../../../modules/assets/src/api/domain/asset-definition"; +import type { AssetInfrastructure } from "../../../modules/assets/src/api/domain/asset-infrastructure.aggregate"; +import { registerInfrastructureRoutes } from "../../../modules/assets/src/api/infrastructure/http/infrastructure.routes"; +import { + InfrastructureResponseSchema, + SavedInfrastructureResponseSchema, +} from "../../../modules/assets/src/common/infrastructure"; +import { UniqueID } from "../../../packages/rdx-ddd/src/index"; +import { Result } from "../../../packages/rdx-utils/src/index"; + +// Repositorio en memoria: el servidor HTTP, los contratos y el dominio son reales. +test("HTTP infraestructura: sesión, pertenencia, validación, identidad e idempotencia", async () => { + const company = UniqueID.generateNewID(); + const asset = UniqueID.generateNewID(); + const actor = UniqueID.generateNewID(); + let current: AssetInfrastructure | undefined; + const actorNames = new Map([[actor.toPrimitive(), "Técnico"]]); + const repository: IInfrastructureRepository = { + get: async (id) => + id.equals(company) + ? Result.ok({ items: current ? [current] : [], actorNames }) + : Result.fail(new AssetCatalogError("COMPANY_NOT_FOUND")), + save: async (id, assetId, change) => { + if (!(id.equals(company) && assetId.equals(asset))) { + return Result.fail(new AssetCatalogError("ASSET_NOT_FOUND")); + } + const changed = change(current); + + if (!changed.isSuccess) { + return Result.fail(changed.error); + } + current = changed.data; + + return Result.ok({ item: current, actorNames }); + }, + }; + const app = express(); + + app.use(express.json()); + registerInfrastructureRoutes( + app, + new InfrastructureUseCases(repository), + (request, response, next) => { + if (request.get("authorization") !== "test") { + response.status(401).json({ code: "UNAUTHENTICATED" }); + + return; + } + Object.defineProperty(request, "session", { value: { userId: actor.toPrimitive() } }); + next(); + } + ); + const server = app.listen(0, "127.0.0.1"); + + await once(server, "listening"); + const address = server.address(); + + assert.ok(address && typeof address !== "string"); + const base = `http://127.0.0.1:${address.port}/api/asset-infrastructure`; + + const request = (path: string, body?: unknown) => + fetch(`${base}/${path}`, { + headers: { authorization: "test", "content-type": "application/json" }, + ...(body ? { method: "POST", body: JSON.stringify(body) } : {}), + signal: AbortSignal.timeout(10000), + }); + + try { + assert.equal( + (await fetch(`${base}/${company.toPrimitive()}`, { signal: AbortSignal.timeout(10000) })) + .status, + 401 + ); + const empty = await request(company.toPrimitive()); + + assert.equal(empty.status, 200); + assert.deepEqual(InfrastructureResponseSchema.parse(await empty.json()).items, []); + const path = `${company.toPrimitive()}/${asset.toPrimitive()}`; + const input = { + roles: ["shared_peripheral"], + interfaces: [ + { + name: "Ethernet", + kind: "ethernet", + mac: "", + notes: "", + addresses: [{ address: "192.168.5.201" }], + }, + { name: "Wi-Fi", kind: "wifi", mac: "", notes: "", addresses: [] }, + ], + }; + + for (const expected of [1, 1]) { + const saved = await request(path, { values: input }); + + assert.equal(saved.status, 200); + const item = SavedInfrastructureResponseSchema.parse(await saved.json()).item; + + assert.equal(item.history.length, expected); + assert.equal(item.history[0]?.actorId, actor.toPrimitive()); + assert.equal(item.values.interfaces[0]?.addresses[0]?.address, "192.168.5.201"); + } + assert.equal( + (await request(path, { values: { ...input, companyId: company.toPrimitive() } })).status, + 400 + ); + assert.equal((await request(`${path}?unexpected=1`, { values: input })).status, 400); + assert.equal( + ( + await request(path, { + values: { + ...input, + interfaces: [{ ...input.interfaces[0], addresses: [{ address: "invalid" }] }], + }, + }) + ).status, + 400 + ); + assert.equal( + ( + await request(`${UniqueID.generateNewID().toPrimitive()}/${asset.toPrimitive()}`, { + values: input, + }) + ).status, + 404 + ); + assert.equal( + ( + await request(`${company.toPrimitive()}/${UniqueID.generateNewID().toPrimitive()}`, { + values: input, + }) + ).status, + 404 + ); + const restored = await request(company.toPrimitive()); + const items = InfrastructureResponseSchema.parse(await restored.json()).items; + + assert.equal(items.length, 1); + assert.equal(items[0]?.history.length, 1); + } finally { + await new Promise((resolve, reject) => + server.close((error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }) + ); + } +}); diff --git a/docs/auditoria-de-infraestructura-informatica.md b/docs/auditoria-de-infraestructura-informatica.md new file mode 100644 index 0000000..6847278 --- /dev/null +++ b/docs/auditoria-de-infraestructura-informatica.md @@ -0,0 +1,383 @@ +# Auditoría de infraestructura informática y propuesta para Metis Desk + +Fecha: 17 de octubre de 2026. Estado: propuesta revisada con el alcance funcional +acordado. El núcleo de la primera entrega está descrito en +[Infraestructura informática](infraestructura-informatica.md); los casos adicionales +se mantienen como ampliaciones posteriores. + +## Resultado y límite acordado + +Metis Desk debe ofrecer un inventario con conexiones entre equipos, interfaces y +sus direcciones IP, y una consulta de dónde se utiliza cada activo. Se conservan +las plantillas, los activos, los puestos, los perfiles y las relaciones existentes. +La vista Infraestructura reúne esa información por compañía. + +Ejemplo del nivel requerido: «La impresora XXX está conectada al switch 1 y se utiliza +en A1, A2, A5 y P1». Si dispone de Ethernet y Wi-Fi, se registran ambas interfaces +con sus respectivas direcciones, sin exigir que ambas estén configuradas o en uso. + +El controlador UniFi ya proporciona al usuario el detalle de su infraestructura de +red. Por tanto, la propuesta de Metis Desk no incluye puertos numerados de switches, +cables, rosetas, ocupación de conectores, VLAN, subredes, tablas de rutas, IPAM avanzado +ni monitorización o descubrimiento automático. Tampoco exige sincronizar UniFi para +poder registrar los datos básicos. Una integración futura requeriría un alcance propio. + +Las interfaces se registran para identificar el medio y sus IP. No representan un +inventario exhaustivo de todos los conectores de un switch. La información detallada +de red permanece en UniFi; Metis Desk mantiene la información de equipos y uso necesaria +para el soporte, sin prometer que ambos sistemas estén sincronizados. + +## Alcance y método de la auditoría + +Revisión de código y documentación activa del repositorio y auditoría documental de +seis productos. Se han consultado fuentes oficiales; no se han instalado ni probado +sus interfaces. La comparación analiza modelos funcionales, no precios, rendimiento +ni disponibilidad comercial de cada función en todas las ediciones. Las conclusiones +para Metis Desk son recomendaciones propias, diferenciadas de lo documentado por los proveedores. + +La comparación de mercado se conserva como referencia. Que una herramienta gestione +puertos o VLAN no convierte esas capacidades en requisitos para Metis Desk. + +No se ha explorado la red de Abeto Design ni modificado su inventario. Este documento +no acredita conexiones o instalaciones reales de esa compañía. + +## Situación actual comprobada + +| Área | Existe hoy | Ajuste necesario para el alcance acordado | +| --- | --- | --- | +| Inventario | Activos con compañía, sede, estado técnico, marca, plantilla e historial | Reunir la infraestructura y facilitar la consulta de sus conexiones y uso | +| Plantillas | Router, firewall, switch, punto de acceso, NAS, servidor, impresora y otros equipos | Mantener sus datos descriptivos; separar las interfaces y direcciones repetibles | +| Relaciones | Conectado a, Alojado en, Depende de, Alimentado por e Instalado en; tipos propios | Reutilizar los vínculos entre activos, suficientes para indicar impresora ↔ switch | +| Puestos | Ordenador principal, responsable opcional, sede y composición | Conservar la exclusividad; los dispositivos compartidos no se duplican entre puestos | +| Perfiles | Contexto de persona y equipo para documentar configuraciones | Mostrar qué equipos y perfiles utilizan cada activo compartido | +| Impresión | Configuración perfil/impresora, USB o red, predeterminada y estados de preparación/retirada | Obtener una lista de equipos usuarios, distinguiendo uso configurado de tareas pendientes | +| Software | Aplicación y licencia como recursos; instalación vinculada a activo | Mantener su responsabilidad propia, separada de las conexiones de red | +| Carpetas | Recursos y configuración de acceso/mapeo con ruta | Relacionar explícitamente el recurso con el servidor o NAS cuando se necesite documentar su anfitrión | + +Evidencias locales: + +- [Catálogo de activos](../modules/assets/src/api/domain/asset-catalog-seed.ts). +- [Reserva exclusiva de miembros del puesto](../modules/assets/src/api/application/workstation-input.mapper.ts). +- [Valores de las relaciones](../modules/assets/src/api/domain/value-objects/asset-relationship-values.vo.ts). +- [Tipos de campos y plantillas](../modules/assets/src/api/domain/asset-definition.ts). +- [Relaciones entre activos](relaciones-entre-activos.md). +- [Puestos de trabajo](puestos-de-trabajo.md). +- [Impresoras del perfil](impresoras-del-perfil.md). +- [Contrato de impresión](../modules/companies/src/api/domain/printer-configuration-values.ts). +- [Contrato de instalación de software](../modules/companies/src/api/domain/software-installation-values.ts). + +Consecuencias: + +1. Una impresora compartida puede registrarse hoy como un único activo y vincularse + a varios perfiles. No necesita incorporarse a cada puesto. +2. Una impresora USB puede formar parte de un puesto y mantener una relación con el + ordenador. Esa composición no acredita una instalación de controlador. +3. Un servidor, NAS, switch o router puede existir sin puesto. Los servidores que hoy + se utilizan excepcionalmente como ordenador principal conservan ese caso de uso; + no se propone migrarlos ni bloquearlos automáticamente. +4. «Conectado a» ya permite representar el nivel de conexión solicitado. No hace falta + sustituirlo por enlaces entre puertos ni representar cada cable paralelo. +5. Falta una estructura sencilla de interfaces y direcciones, junto con una vista + consolidada del uso que aproveche las configuraciones ya existentes. + +## Comparación documental + +| Herramienta | Tratamiento documentado | Aprendizaje para Metis Desk | +| --- | --- | --- | +| GLPI | Puertos asociados a equipos, Ethernet/Wi-Fi y variantes virtuales; conexiones entre puertos, MAC, VLAN e historial | Tomar el inventario y las interfaces; mantener el detalle de puertos en UniFi | +| iTop 3.3 | Dispositivos, servidores e impresoras como elementos propios; interfaces, VLAN y relaciones de impacto/dependencia | Separar el dispositivo de las funciones y dependencias que presta | +| Jira Service Management Assets | Objetos relacionados por referencias entrantes/salientes y tipos de referencia | Mantener referencias navegables; una ficha aislada no explica la infraestructura | +| ServiceNow CMDB | Relaciones diferenciadas de conexión, alojamiento, dependencia y pertenencia | Dar significado explícito a cada relación y distinguir conexión de impacto | +| ManageEngine ServiceDesk Plus | CMDB con mapas de relaciones y dependencias; integración con herramientas de observabilidad | Consultar conexiones y uso; las integraciones no son requisito de esta propuesta | +| NetBox | Interfaces de dispositivos, IP, VLAN y cableado entre extremos | Tomar la asociación de direcciones a interfaces; su cableado e IPAM exceden el alcance acordado | + +Fuentes y alcance de las afirmaciones: + +- **GLPI:** [Network ports](https://help.glpi-project.org/documentation/tabs/network_ports) + y [Connections](https://help.glpi-project.org/documentation/tabs/connections). + La documentación distingue conexiones de equipos y puertos de red y permite datos manuales o de inventario automático. +- **iTop:** [CMDB 3.3](https://www.itophub.io/wiki/page?id=3_3_0:datamodel:itop-config-mgmt) + y [dispositivos de usuario 3.3](https://www.itophub.io/wiki/page?id=3_3_0:datamodel:itop-endusers-devices). + Se documentan dispositivos de red, interfaces físicas, VLAN e impresoras conectadas a red o PC. +- **Atlassian:** [referencias entre objetos](https://support.atlassian.com/assets/docs/what-is-a-reference/). + Las referencias tienen dirección y un tipo que expresa su significado. +- **ServiceNow:** [relaciones entre CI](https://www.servicenow.com/docs/r/de-DE/servicenow-platform/configuration-management-database-cmdb/c_CIRelationships.html). + La tabla distingue Connects to, Depends on, Hosted on y otras relaciones. +- **ManageEngine:** [CMDB de ServiceDesk Plus](https://www.manageengine.com/products/service-desk/itsm/it-cmdb-software.html) + y [sincronización con OpManager](https://www.manageengine.com/network-monitoring/syncing-relationship-data.html). + La descripción del proveedor contempla mapas e integración; no implica que toda edición los incluya. +- **NetBox:** [interfaces](https://netbox.readthedocs.io/en/stable/models/dcim/interface/), + [direcciones IP](https://netbox.readthedocs.io/en/stable/models/ipam/ipaddress/) + y [cables](https://netbox.readthedocs.io/en/stable/models/dcim/cable/). + La documentación sitúa IP/VLAN en interfaces y representa conexiones físicas mediante extremos de cable. + +La conclusión transversal de esta revisión es que los equipos compartidos conservan +su identidad y se conectan a otros registros. La asignación personal es una dimensión +más, junto a ubicación, conexiones y uso. + +## Modelo funcional recomendado + +| Concepto | Qué representa | Ejemplo | +| --- | --- | --- | +| Plantilla de activo | Datos descriptivos de un tipo de equipo | Impresora, NAS, switch | +| Activo | Un equipo concreto, físico o virtual cuando corresponda | Impresora XXX, switch 1, ordenador A1 | +| Puesto | Equipo principal y elementos asignados exclusivamente al conjunto | PC + monitores + impresora USB dedicada | +| Perfil | Contexto de usuario/equipo y sus configuraciones | Perfil de una persona en A1 | +| Interfaz | Medio de conexión de un activo que interesa documentar | Ethernet LAN, Wi-Fi, interfaz de gestión | +| Dirección IP | Dirección asociada a una interfaz concreta | Ethernet: 192.168.5.201; Wi-Fi: 192.168.5.202 | +| Conexión entre activos | Vínculo general entre dos equipos | Impresora XXX conectada al switch 1 | +| Configuración de uso | Cómo un perfil o equipo consume un activo o recurso | Impresora configurada en A1 o carpeta mapeada | +| Recurso publicado | Algo consumible ofrecido por un anfitrión | Carpeta compartida de un NAS | + +### Plantillas y clasificación + +Marca, modelo, serie, capacidad, características de hardware, firmware y datos +específicos del producto siguen en las plantillas. Hubs, gateways, controladoras y +otros equipos pueden usar plantillas propias. + +Una clasificación funcional estable debe permitir filtrar familias sin depender +del nombre traducido ni del icono. Un gateway también puede hacer de router, +firewall y punto de acceso: registrar un solo aparato y sus funciones. La clasificación +exacta se debe acordar antes de modificar contratos; el icono sigue siendo presentación. + +Las interfaces y sus IP requieren estructuras relacionadas. Evitar campos fijos +IP Ethernet/IP Wi-Fi, IP1/IP2 o listas separadas por comas, porque un activo puede +tener varias interfaces del mismo tipo y una interfaz puede tener varias direcciones. + +### Interfaces e IP: información mínima + +Por cada interfaz que interese documentar: + +- Nombre o identificador comprensible: Ethernet, LAN1, Wi-Fi o Gestión. +- Tipo de conexión, inicialmente Ethernet, Wi-Fi y Otro. +- Cero, una o varias direcciones IP, admitiendo IPv4 e IPv6. +- MAC opcional y observaciones opcionales. + +Una impresora puede tener Ethernet y Wi-Fi aunque solo Ethernet tenga una IP conocida. +Una interfaz sin dirección conocida no obliga a inventarla ni a borrar la interfaz. +En las tablas, las celdas sin valor muestran «—» conforme al patrón común. + +La IP identifica una configuración, no la identidad del activo. Cambiar una IP conserva +el activo y su historial. Una misma dirección privada puede existir en compañías +diferentes. Dentro de una compañía, una coincidencia puede generar un aviso revisable; +no imponer unicidad absoluta sin conocer los contextos de red, que quedan fuera del alcance. +No es necesario administrar prefijos, máscaras, rangos, DHCP ni VLAN para guardar una IP. + +### Conexiones entre equipos + +Reutilizar «Conectado a» como vínculo entre dos activos de la misma compañía. +Debe poder consultarse en ambas fichas y conservar su vigencia e historial actual. +Ejemplos: impresora ↔ switch, PC ↔ switch, AP ↔ switch y NAS ↔ switch. + +La conexión se registra aunque no se conozca la interfaz. Si interesa asociarla a +una interfaz documentada, se puede añadir una referencia opcional sobre ese mismo +vínculo, sin crear una segunda conexión independiente ni exigir el puerto remoto. +La concreta necesidad de esa referencia se resolverá en el diseño técnico. + +Las relaciones existentes siguen siendo válidas y completas para este nivel de +detalle. No se marcan como incompletas por carecer de extremos de cable. Una sola +relación puede resumir la conexión entre dos equipos, incluso si UniFi conoce varios +enlaces físicos. Las asociaciones se documentan; no acreditan disponibilidad en tiempo real. + +### Uso exclusivo, compartido y «Dónde se utiliza» + +La exclusividad pertenece a la composición activa del puesto. Un activo compartido +puede ser utilizado por varios perfiles o equipos sin adquirir varias identidades ni +reservas exclusivas. Mantener la protección del ordenador y sus periféricos dedicados. + +Para impresoras, la fuente de verdad del uso son las configuraciones de impresión +existentes. Desde ellas se obtiene el equipo de cada perfil y, cuando exista, su +puesto. La consulta «Dónde se utiliza» muestra equipos únicos —A1, A2, A5, P1— y +permite desplegar sus perfiles/usuarios y su configuración de impresora predeterminada. +Si varios perfiles de A1 utilizan la impresora, A1 aparece una sola vez en el resumen. + +Distinguir configurada, pendiente de configurar y pendiente de retirar. Las configuraciones +retiradas o que nunca llegaron a configurarse permanecen en el historial, sin presentarse +como uso actual. Los avisos de referencias inactivas se conservan. No afirmar un uso +real observado: la información describe las configuraciones registradas en Metis Desk. + +No mantener una segunda lista editable de ordenadores usuarios para las mismas +configuraciones. Si aparece un uso a nivel de equipo sin perfil, se diseñará una +asociación específica para ese caso y se integrará en la consulta agregada. + +Estar conectado a un switch no significa utilizar la impresora. Estar en la misma +sede tampoco concede acceso. Conexión, uso y asignación exclusiva son dimensiones distintas. + +### Impresión local y compartida + +| Caso | Conexión documentada | Uso documentado | +| --- | --- | --- | +| USB dedicada | Impresora ↔ PC; puede pertenecer al puesto | Perfil configura esa impresora local | +| Impresora Ethernet | Impresora ↔ switch; interfaz Ethernet con sus IP si se conocen | Varios perfiles/equipos configuran el mismo activo | +| Impresora Wi-Fi | Impresora ↔ AP cuando interese y se conozca; interfaz Wi-Fi con sus IP | Varios perfiles/equipos configuran el mismo activo | +| USB compartida desde un PC | Impresora ↔ PC anfitrión; no necesita una IP propia | Otros equipos utilizan la impresora compartida desde ese anfitrión | + +Para impresoras publicadas por un PC o servidor, registrar el anfitrión y el nombre +compartido o ruta cuando ese caso lo requiera. El modelo completo de colas y servidores +de impresión se reserva para una ampliación posterior; no es requisito para consultar +los equipos que hoy utilizan una impresora de red mediante los perfiles existentes. + +### Servidores, NAS y recursos + +Registrar cada servidor o NAS una vez, con sus interfaces/IP y conexiones generales. +Los roles —archivos, dominio, DNS, DHCP, impresión o aplicaciones— describen sus funciones; +no crear un activo duplicado por cada función ni exigir un puesto para administrarlo. + +Una VM puede ser otro activo relacionado mediante «Alojado en», conservando identidad +e historial al cambiar de anfitrión. El NAS es el dispositivo y sus carpetas son recursos. +La relación explícita entre recurso y anfitrión se incorporará cuando ese caso sea necesario, +conservando los permisos y configuraciones actuales. No forma parte del mínimo de red +el modelado exhaustivo de servicios, clusters o dependencias de aplicaciones. + +## Ejemplo de ficha de una impresora + +Ejemplo ilustrativo del alcance, no inventario ni conexiones verificados de Abeto Design: + +| Apartado | Contenido | +| --- | --- | +| Información | Impresora XXX, modelo, serie, sede y estado | +| Interfaces | Ethernet: 192.168.5.201; Wi-Fi: 192.168.5.202 | +| Conexiones | Switch 1 por Ethernet; AP 1 por Wi-Fi, si se conoce y está documentado | +| Dónde se utiliza | A1, A2, A5 y P1; perfiles y estados de configuración asociados | +| Historial | Cambios de datos, direcciones, conexiones y configuraciones | + +Las dos direcciones son ilustrativas; no se presupone que ambas interfaces estén +configuradas o conectadas simultáneamente. Tampoco es obligatorio conocer el AP. + +```mermaid +flowchart LR + Printer[Impresora XXX] ---|Conectada a| Switch[Switch 1] + Printer --- Ethernet[Ethernet: 192.168.5.201] + Printer --- Wifi[Wi-Fi: 192.168.5.202] + A1[Equipo A1] -. Configuración de impresión .-> Printer + A2[Equipo A2] -. Configuración de impresión .-> Printer + A5[Equipo A5] -. Configuración de impresión .-> Printer + P1[Equipo P1] -. Configuración de impresión .-> Printer +``` + +El esquema muestra conexión, interfaces y uso. Las líneas de uso son una consulta +agregada de los perfiles, no nuevas asociaciones editables que dupliquen sus datos. + +## Diseño de persistencia propuesto + +Propuesta conceptual, no esquema aprobado ni migración lista para ejecutar: + +- `asset_interfaces`: identidad UUID v7, compañía, activo, nombre, tipo de conexión, + MAC opcional y observaciones. Solo interfaces relevantes para documentar el activo. +- `asset_interface_addresses`: identidad UUID v7, interfaz y dirección IP normalizada. + Mantener las referencias y validaciones de pertenencia a compañía. +- `asset_relationships`: reutilizar la entidad existente para conexiones entre equipos, + alojamiento y dependencias. No crear `network_connections` para puertos o cables. +- `printer_configurations`: conservar la fuente de verdad del uso de impresoras; + obtener equipos/perfiles/puestos mediante consultas, sin copiar listas de usuarios. + +No se proponen tablas de puertos de switch, cableado, VLAN, segmentos, pools o rangos +IP. Las futuras publicaciones de recursos o usos sin perfil se diseñarán por separado +cuando existan casos concretos, sin bloquear esta entrega. + +Todas las entidades de negocio, incluidas las internas, requieren identidad propia. +Mantener FK, validación de compañía, transacciones, historial, idempotencia y migraciones +explícitas. La dirección no sustituye el UUID del activo o interfaz. Las referencias +repetibles se almacenan de forma relacional, sin listas de IP en texto ni JSON. +Los módulos consumidores acceden por servicios públicos; el ORM permanece en Infrastructure. +MariaDB y la arquitectura existente cubren este alcance. + +Los campos IP o MAC que ya existan en características o notas requieren una revisión +antes de proponer migración. No eliminarlos ni reinterpretarlos automáticamente. +Una migración futura debe identificar la interfaz cuando se conozca, conservar el +historial y establecer una única fuente editable para la información normalizada. + +## Cambios de interfaz recomendados + +Añadir **Infraestructura** en la compañía como vista del mismo inventario, con filtros +de equipos de red, servidores/almacenamiento y periféricos compartidos. Mostrar nombre, +tipo, sede, estado, direcciones y vínculos generales según el contenido disponible. +Estos filtros necesitan clasificación funcional explícita; no deben usar iconos como +reglas de negocio. Un equipo puede aparecer en varias vistas por sus funciones. + +La ficha del activo reúne **Información**, **Interfaces**, **Conexiones**, +**Dónde se utiliza** e **Historial**, según corresponda. Los apartados pueden ser +secciones de la ficha; no se exige crear una pestaña por cada uno. Reutilizar +componentes, listados, selectores y diálogos existentes. + +- Impresora: interfaces con IP, dispositivos conectados y equipos/perfiles que la utilizan. +- Switch: equipos conectados a nivel de activo, sin cuadrícula ni ocupación de puertos. +- Servidor/NAS: interfaces, conexiones y relaciones de alojamiento o recursos ya documentadas. +- Puesto: componentes exclusivos y consulta de uso compartido derivada de sus perfiles, + sin incorporar la impresora compartida a su reserva exclusiva. + +Una representación gráfica sencilla es opcional y posterior a las consultas en tabla. +Un acceso manual al controlador UniFi puede añadirse si resulta útil; no se presupone +API, descubrimiento, enlace profundo a un dispositivo ni sincronización automática. + +## Alternativas y recomendación + +| Alternativa | Ventaja | Problema | Decisión propuesta | +| --- | --- | --- | --- | +| IP y uso en notas o campos fijos | Entrega mínima | No permite consultar y mantener varias interfaces/IP o referencias con claridad | Documentación transitoria | +| Quitar la exclusividad de los puestos | Aparente simplicidad | Mezcla reserva, conexión y uso compartido | Descartar | +| Inventario, relaciones entre equipos, interfaces/IP y uso derivado | Aprovecha el modelo actual y cubre el límite solicitado | Requiere interfaces/IP y consultas consolidadas | Recomendada | +| Replicar el detalle de red de UniFi | Más información técnica | Duplica una función que el usuario ya tiene cubierta | Fuera del alcance | + +La recomendación es propia. De GLPI se toma el inventario y la distinción entre medios; +de iTop, Atlassian y ServiceNow, las relaciones; de NetBox, el concepto de direcciones +asociadas a interfaces. Sus funciones más amplias no se trasladan como obligaciones. + +## Orden de implantación y aceptación + +### 1. Hacer visible la infraestructura y su uso + +Acordar la clasificación funcional y crear la vista de infraestructura. Reutilizar +«Conectado a» entre activos y ofrecer la consulta de conexiones desde ambos extremos. +Mostrar en impresoras los equipos que las utilizan, derivados de perfiles y configuraciones. +Separar estado técnico, reserva exclusiva y uso compartido al presentarlos. + +Aceptación: + +- Una impresora está conectada al switch 1 sin introducir ningún puerto. +- A1, A2, A5 y P1 pueden utilizarla sin duplicar su activo ni sus reservas de puesto. +- Dos perfiles de A1 no duplican A1 en la lista agregada. +- Se distinguen configuraciones activas, pendientes de configurar/retirar e historial. +- Un servidor, NAS o switch se administra sin crear un puesto. + +### 2. Incorporar interfaces y direcciones + +Alta y edición de interfaces relevantes para el activo, con sus direcciones IP, +validación e historial. Captura manual; UniFi no es una dependencia para guardar. +No exigir conocer un switch, AP, subred o puerto para registrar una interfaz o IP. + +Aceptación: + +- Una impresora conserva Ethernet y Wi-Fi con IP diferentes. +- Puede guardarse una interfaz sin IP conocida o con varias IPv4/IPv6. +- Un activo puede tener dos interfaces Ethernet diferenciadas por nombre. +- Cambiar o retirar una IP conserva la identidad del activo y su historial. +- Dos compañías pueden registrar la misma IP privada sin conflicto. +- Guardar los mismos datos no añade revisiones; las referencias de otra compañía se rechazan. + +### 3. Completar casos concretos de publicación y uso + +Cuando se necesite: anfitrión de carpeta compartida, impresora USB compartida desde +un PC, alojamiento de VM o uso de un activo a nivel de equipo sin perfil. +Reutilizar las asociaciones existentes y evitar registros duplicados de un mismo uso. +El modelo exhaustivo de colas o servicios requerirá su propio diseño. + +### 4. Mejoras opcionales de consulta + +Mapa sencillo entre equipos y acceso al controlador UniFi, si aportan utilidad. +Una integración automática se evaluará como tarea separada con origen, reconciliación +e historial; no implica ampliar el alcance a gestión de cableado, IPAM o monitorización. + +## Decisiones de alcance para la siguiente entrega + +1. El puesto mantiene su composición exclusiva; la infraestructura compartida tiene activos propios. +2. Las conexiones son entre equipos, sin detalle de puertos ni cables. +3. Cada activo puede tener varias interfaces y cada interfaz cero o varias IP. +4. El uso de impresoras se consulta desde las configuraciones de sus perfiles, agrupado por equipo. +5. Infraestructura será una vista del mismo inventario y sus asociaciones, sin duplicarlo. +6. UniFi conserva el detalle de red; una integración no es necesaria para esta propuesta. +7. La entrega comienza por conexiones/uso visibles e interfaces/IP. Los casos adicionales + de recursos publicados se incorporan conforme se necesiten. + +Este documento actualiza la propuesta funcional. No implementa cambios de código, +no modifica datos y no cambia por sí mismo las reglas vigentes del dominio. diff --git a/docs/componentes-de-interfaz.md b/docs/componentes-de-interfaz.md index b0ccd8c..f568a67 100644 --- a/docs/componentes-de-interfaz.md +++ b/docs/componentes-de-interfaz.md @@ -1044,3 +1044,22 @@ externas, sus datos propios y sus proyecciones de buzón o acceso dentro de una Tras borrar se limpia la selección afectada y se mantienen la búsqueda y la pestaña. Las altas, ediciones y cambios de estado o borrado reutilizan `resourceRelatedQueryKeys` para actualizar inventario y listados consumidores. + + +### Infraestructura e interfaces de activos + +`InfrastructurePage` presenta una vista del inventario con clasificación funcional y +relaciones entre equipos. Reutiliza `EntityListHeader`, `EntityTable`, `EntityRowActions` +y `AssetSelect`. `AssetInfrastructureSections` compone el apartado de interfaces y el +contenido de uso aportado por la compañía. `AssetInfrastructurePanel` consulta datos e +historial; `InfrastructureEditorDialog` encapsula el modal con `FormDialog layout="stable"`, +React Hook Form, `FormBody`, `FormHelp` y `FormActions`. `InterfaceFields` administra los +campos y direcciones de una interfaz, con etiquetas y errores locales. + +`InventoryLookups.renderUsage` permite que Companies aporte `AssetPrinterUsage` sin +introducir dependencias de Assets hacia Companies. El componente agrupa por equipo +las configuraciones de impresión y muestra perfiles, estados e historial. Las consultas +comparten claves y estados remotos existentes. Son componentes propios de sus módulos; +las primitivas visuales genéricas siguen en @repo/rdx-ui y @repo/shadcn-ui. + +Véase [Infraestructura informática](infraestructura-informatica.md). diff --git a/docs/infraestructura-informatica.md b/docs/infraestructura-informatica.md new file mode 100644 index 0000000..e5fd5a5 --- /dev/null +++ b/docs/infraestructura-informatica.md @@ -0,0 +1,129 @@ +# Infraestructura informática de la compañía + +Implementación del núcleo acordado en la +[auditoría de infraestructura](auditoria-de-infraestructura-informatica.md): conexiones +entre equipos, interfaces/IP y consulta del uso documentado de impresoras. + +## Acceso y clasificación + +La compañía incorpora **Infraestructura** en su navegación. La ruta es +`/companies/:companyId/infrastructure`. Es una vista del mismo inventario de activos; +no crea otra copia de los equipos. + +Las funciones disponibles son Red, Servidor, Almacenamiento y Periférico compartido. +Un activo puede tener varias. Los routers, firewalls, switches y puntos de acceso de +serie se presentan por defecto en Red; los servidores en Servidor y los NAS en +Almacenamiento. El criterio usa `builtinKey`, publicado por el catálogo de activos, +no el icono ni el nombre traducido. Las clasificaciones guardadas prevalecen sobre +esos valores de serie. Las plantillas propias se clasifican expresamente. + +**Añadir a infraestructura** reutiliza el selector de activos y abre el editor sobre +el activo elegido. También se accede desde **Interfaces y direcciones** en la ficha +lateral o en la ficha completa de cualquier activo. Marcar Periférico compartido no +reserva el activo para un puesto ni instala configuraciones de impresión. + +El listado admite búsqueda, filtros por función, columnas y ordenación del patrón +común. Muestra interfaces/IP, estado técnico, puestos asignados y relaciones vigentes con +enlaces a las fichas de ambos extremos. Para dar de alta un equipo nuevo se utiliza +el alta existente de Activos, conservando sus plantillas, sedes e historial. + +## Interfaces y direcciones + +El editor tiene altura estable, scroll interno y ayuda junto al pie. Las interfaces +admiten nombre, tipo Ethernet/Wi-Fi/Otra, MAC opcional, notas y cero o varias IP. +Máximo 32 interfaces por activo y 32 direcciones por interfaz. Puede haber varias +interfaces Ethernet con nombres distintos. Los nombres son únicos dentro del activo, +ignorando mayúsculas y espacios exteriores. + +Las direcciones admiten IPv4 e IPv6 sin máscara. Se normalizan las IPv6 y las MAC; +no se admite repetir una dirección dentro de la misma interfaz. No hay unicidad +global por dirección: dos compañías, o dos interfaces, pueden documentar la misma IP. +No se infieren subredes, VLAN ni rutas. Una interfaz sin IP conocida se conserva. + +Cada interfaz y dirección tiene identidad UUID v7, independiente de su nombre o IP. +El editor conserva esas identidades al modificar datos. Repetir un guardado equivalente +no añade revisión. Se rechazan identificadores de interfaces o direcciones que no +pertenezcan a la configuración que se está editando. + +Cada cambio guarda una instantánea con actor y fecha; la ficha permite consultar +interfaces, direcciones, notas y funciones anteriores. Retirar una interfaz o una IP +del formulario no borra sus instantáneas históricas. + +Las IP/MAC que ya existían en características o notas permanecen intactas. Su traslado +al nuevo apartado requiere revisar a qué interfaz corresponden; no hay una conversión +que invente conexiones o elimine datos anteriores. + +## Conexiones entre equipos + +Se reutiliza [Relaciones entre activos](relaciones-entre-activos.md). **Conectado a** +es suficiente para indicar impresora ↔ switch o NAS ↔ switch. Se consulta en las +fichas de ambos extremos. No se requieren puertos numerados ni conexiones a nivel +de interfaz para guardar esa relación. También se conservan Alojado en y los demás +tipos de relación existentes. + +UniFi conserva el detalle de puertos y cableado. Este núcleo no incorpora sincronización, +descubrimiento, monitorización, VLAN o IPAM avanzado. Las relaciones representan +información documentada, no una comprobación automática de conectividad actual. + +## Dónde se utiliza una impresora + +La ficha del activo en el espacio de compañía presenta **Dónde se utiliza**, a partir +de `printer_configurations` y los perfiles existentes. La composición se inyecta desde +Companies: Assets no importa repositorios, API ni componentes de Companies. + +Se agrupa por equipo, con enlace a su ficha. Varios perfiles del mismo equipo producen +una sola entrada de equipo. Dentro se muestran los perfiles, persona, conexión USB/red, +estado, condición de predeterminada y avisos de revisión. Se conservan los estados +Pendiente de configurar, Configurada y Pendiente de retirar; las configuraciones +Retirada y No llegó a configurarse aparecen aparte como historial. + +Si una referencia no se resuelve, se informa de ello; los errores de consulta no se +presentan como ausencia de uso. Los datos se obtienen de las consultas compartidas de +inventario, perfiles e impresoras y admiten reintento. + +La consulta describe configuraciones registradas, no actividad de impresión observada. +No mantiene una segunda lista editable de equipos usuarios. La reserva exclusiva del +puesto no se modifica: una impresora compartida puede usarse en varios perfiles sin +incorporarse a sus composiciones. + +## Dominio, persistencia y HTTP + +`AssetInfrastructure` es el agregado de la documentación de infraestructura de un +activo. Contiene interfaces con direcciones identificadas y revisiones inmutables. +Los valores normalizados comparan contenido semánticamente, sin serializar JSON. + +La migración explícita se integra en el arranque del módulo de activos y es idempotente. +Tablas relacionales: + +- `asset_infrastructure`: identidad, compañía y activo, único por activo. +- `asset_infrastructure_revisions`: actor, fecha y posición de cada instantánea. +- `asset_infrastructure_roles`: funciones por revisión. +- `asset_interface_snapshots`: valores e identidad de las interfaces por revisión. +- `asset_interface_address_snapshots`: valores e identidad de direcciones por interfaz/revisión. + +Las FK compuestas protegen la pertenencia activo/compañía y dirección/interfaz/revisión. +Las revisiones se añaden; los datos actuales se obtienen de la última instantánea. +El borrado definitivo permitido de un activo limpia también estos datos propios dentro +de la misma transacción. Las asociaciones externas siguen bloqueando ese borrado. + +`GET /api/asset-infrastructure/:companyId` devuelve configuraciones e historial. +`POST /api/asset-infrastructure/:companyId/:assetId` guarda `{ values }` con funciones +e interfaces. Los contratos Zod validan entrada y salida. La sesión aporta el actor; +las rutas usan la autenticación común y las escrituras la protección HTTP del servidor. +El repositorio valida la compañía, verifica la pertenencia del activo y bloquea la +compañía durante la escritura para proteger el guardado concurrente. + +## Validación y límites + +Pruebas de normalización, límites, nombres/direcciones duplicados, identidad e historial, +repetición sin cambios, clasificación independiente de iconos y uso agrupado por equipo. +Las pruebas web comprueban es/en y referencias reales a fichas. Las pruebas HTTP usan +Express real y un repositorio en memoria; comprueban sesión, validación y pertenencia. +La prueba MariaDB utiliza tablas temporales en `metis_desk_test` para verificar escritura, +rehidratación, historial y limpieza de los datos propios. La migración se ha aplicado +y repetido en esa base para comprobar las nuevas restricciones. + +Los anfitriones de recursos publicados, colas de impresión, usos sin perfil y mapas +opcionales mantienen el carácter de ampliaciones posteriores de la propuesta. No son +requisitos para registrar interfaces/IP, conexiones entre equipos o el uso de impresión +ya documentado mediante perfiles. diff --git a/docs/inventario-de-modales.md b/docs/inventario-de-modales.md index d5c3fd2..2722f1a 100644 --- a/docs/inventario-de-modales.md +++ b/docs/inventario-de-modales.md @@ -169,3 +169,13 @@ El fallo de empresas se reprodujo ejecutando directamente el fichero: una prueba espera el texto «Esta persona no tiene acceso a Metis Desk», ausente en el render actual. No se ha modificado esa prueba ni el contenido de la ayuda para ocultar el fallo. Los módulos de tickets, intervenciones y sus plantillas permanecen [archivados](funcionalidades-archivadas.md). + + +## Ampliación: interfaces e infraestructura (17 de octubre de 2026) + +`InfrastructureEditorDialog`, en Assets, edita funciones e interfaces/IP de un activo. +Vive en su propio fichero y utiliza `FormDialog layout="stable"`, cuerpo con scroll, +ayuda inferior y acciones fijas. La selección de un activo existente reutiliza +`AssetPickerDialog`, con sus reglas actuales de foco, selección y altura. +Esta ampliación se añade al inventario anterior; no cambia el recuento histórico de +la revisión del 15 de octubre. Véase [Infraestructura informática](infraestructura-informatica.md). diff --git a/modules/assets/package.json b/modules/assets/package.json index 0d8f5d4..b785f70 100644 --- a/modules/assets/package.json +++ b/modules/assets/package.json @@ -10,7 +10,7 @@ }, "scripts": { "typecheck": "tsc -p tsconfig.json", - "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/api/domain/workstation.test.ts src/api/domain/asset-relationship.test.ts src/web/catalog/shared/catalog.test.tsx src/web/assets/shared/inventory.test.tsx src/api/domain/support.test.ts src/web/support-connections/shared/support.test.tsx src/api/domain/brand.test.ts src/web/brands/shared/brands.test.tsx src/api/domain/template-sections.test.ts src/web/workstations/shared/helpers/workstation-creation.test.ts src/web/workstations/shared/workstation-name.test.ts src/web/workstations/rename/api/rename-workstation.test.ts src/web/assets/list/asset-workstation.test.tsx src/web/assets/select/asset-selection.test.tsx src/web/catalog-fields/select/catalog-field-selection.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/infrastructure.test.ts src/api/domain/workstation.test.ts src/api/domain/asset-relationship.test.ts src/web/catalog/shared/catalog.test.tsx src/web/assets/shared/inventory.test.tsx src/web/infrastructure/shared/infrastructure.test.tsx src/api/domain/support.test.ts src/web/support-connections/shared/support.test.tsx src/api/domain/brand.test.ts src/web/brands/shared/brands.test.tsx src/api/domain/template-sections.test.ts src/web/workstations/shared/helpers/workstation-creation.test.ts src/web/workstations/shared/workstation-name.test.ts src/web/workstations/rename/api/rename-workstation.test.ts src/web/assets/list/asset-workstation.test.tsx src/web/assets/select/asset-selection.test.tsx src/web/catalog-fields/select/catalog-field-selection.test.tsx" }, "dependencies": { "@hookform/resolvers": "5.2.2", diff --git a/modules/assets/src/api/application/asset-catalog-response.builder.ts b/modules/assets/src/api/application/asset-catalog-response.builder.ts index 740f821..ac17da4 100644 --- a/modules/assets/src/api/application/asset-catalog-response.builder.ts +++ b/modules/assets/src/api/application/asset-catalog-response.builder.ts @@ -13,6 +13,7 @@ export function buildCatalogResponse( id: item.id.toPrimitive(), companyId: item.companyId.getOrUndefined()?.toPrimitive() ?? null, builtin: item.stockKey.getOrUndefined() !== undefined, + builtinKey: item.stockKey.getOrUndefined() ?? null, ...(item.stockKey.getOrUndefined() === "field:brand" ? { referenceCatalog: "brands" as const } : {}), diff --git a/modules/assets/src/api/application/infrastructure-response.builder.ts b/modules/assets/src/api/application/infrastructure-response.builder.ts new file mode 100644 index 0000000..d4c07e5 --- /dev/null +++ b/modules/assets/src/api/application/infrastructure-response.builder.ts @@ -0,0 +1,21 @@ +import type { InfrastructureDTO } from "../../common/infrastructure"; +import type { AssetInfrastructure } from "../domain/asset-infrastructure.aggregate"; + +export function buildInfrastructureResponse( + item: AssetInfrastructure, + actors: ReadonlyMap +): InfrastructureDTO { + return { + id: item.id.toPrimitive(), + companyId: item.companyId.toPrimitive(), + assetId: item.assetId.toPrimitive(), + values: item.values.toPrimitive(), + history: item.history.map((revision) => ({ + id: revision.id.toPrimitive(), + values: revision.values.toPrimitive(), + actorId: revision.actorId.toPrimitive(), + actorName: actors.get(revision.actorId.toPrimitive()) ?? "—", + recordedAt: revision.recordedAt, + })), + }; +} diff --git a/modules/assets/src/api/application/infrastructure.repository.ts b/modules/assets/src/api/application/infrastructure.repository.ts new file mode 100644 index 0000000..f573fae --- /dev/null +++ b/modules/assets/src/api/application/infrastructure.repository.ts @@ -0,0 +1,28 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import type { Result } from "@repo/rdx-utils"; + +import type { AssetCatalogError } from "../domain/asset-definition"; +import type { AssetInfrastructure } from "../domain/asset-infrastructure.aggregate"; + +export interface IInfrastructureRepository { + get( + company: UniqueID + ): Promise< + Result< + { items: readonly AssetInfrastructure[]; actorNames: ReadonlyMap }, + AssetCatalogError + > + >; + save( + company: UniqueID, + asset: UniqueID, + change: ( + previous: AssetInfrastructure | undefined + ) => Result + ): Promise< + Result< + { item: AssetInfrastructure; actorNames: ReadonlyMap }, + AssetCatalogError + > + >; +} diff --git a/modules/assets/src/api/application/infrastructure.use-cases.ts b/modules/assets/src/api/application/infrastructure.use-cases.ts new file mode 100644 index 0000000..ac0e7ac --- /dev/null +++ b/modules/assets/src/api/application/infrastructure.use-cases.ts @@ -0,0 +1,44 @@ +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { AssetCatalogError } from "../domain/asset-definition"; +import { AssetInfrastructure } from "../domain/asset-infrastructure.aggregate"; +import type { InfrastructureInput } from "../domain/infrastructure-values"; +import { InfrastructureValues } from "../domain/value-objects/infrastructure-values.vo"; + +import type { IInfrastructureRepository } from "./infrastructure.repository"; + +export class InfrastructureUseCases { + constructor(private readonly repository: IInfrastructureRepository) {} + + get(companyId: string) { + const company = UniqueID.create(companyId); + + return company.isSuccess + ? this.repository.get(company.data) + : Promise.resolve(Result.fail(new AssetCatalogError("INVALID_REQUEST"))); + } + + save(companyId: string, assetId: string, input: InfrastructureInput, actorId: string) { + const company = UniqueID.create(companyId); + const asset = UniqueID.create(assetId); + const actor = UniqueID.create(actorId); + + if (!(company.isSuccess && asset.isSuccess && actor.isSuccess)) { + return Promise.resolve(Result.fail(new AssetCatalogError("INVALID_REQUEST"))); + } + + return this.repository.save(company.data, asset.data, (previous) => { + const values = InfrastructureValues.create(input, previous?.values); + + if (!values.isSuccess) { + return Result.fail(values.error); + } + const now = new Date().toISOString(); + + return previous + ? previous.update(values.data, actor.data, now) + : AssetInfrastructure.create(company.data, asset.data, values.data, actor.data, now); + }); + } +} diff --git a/modules/assets/src/api/domain/asset-infrastructure.aggregate.ts b/modules/assets/src/api/domain/asset-infrastructure.aggregate.ts new file mode 100644 index 0000000..9a47104 --- /dev/null +++ b/modules/assets/src/api/domain/asset-infrastructure.aggregate.ts @@ -0,0 +1,87 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import { AggregateRoot } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import { z } from "zod/v4"; + +import { AssetCatalogError } from "./asset-definition"; +import { InfrastructureRevision } from "./infrastructure-revision.entity"; +import type { InfrastructureValues } from "./value-objects/infrastructure-values.vo"; + +type InfrastructureProps = Readonly<{ + companyId: UniqueID; + assetId: UniqueID; + history: readonly InfrastructureRevision[]; +}>; + +export class AssetInfrastructure extends AggregateRoot { + private constructor(props: InfrastructureProps, id?: UniqueID) { + super(Object.freeze({ ...props, history: Object.freeze([...props.history]) }), id); + } + + static create( + companyId: UniqueID, + assetId: UniqueID, + values: InfrastructureValues, + actorId: UniqueID, + recordedAt: string + ) { + if (!z.iso.datetime().safeParse(recordedAt).success) { + return Result.fail(new AssetCatalogError("INVALID_REQUEST")); + } + + return Result.ok( + new AssetInfrastructure({ + companyId, + assetId, + history: [InfrastructureRevision.create({ values, actorId, recordedAt })], + }) + ); + } + + static rehydrate(id: UniqueID, props: InfrastructureProps) { + return new AssetInfrastructure(props, id); + } + + get companyId() { + return this.props.companyId; + } + + get assetId() { + return this.props.assetId; + } + + get history() { + return this.props.history; + } + + get values() { + const latest = this.history.at(-1); + + if (!latest) { + throw new Error("Infraestructura sin revisión."); + } + + return latest.values; + } + + update(values: InfrastructureValues, actorId: UniqueID, recordedAt: string) { + if (!z.iso.datetime().safeParse(recordedAt).success) { + return Result.fail(new AssetCatalogError("INVALID_REQUEST")); + } + + return Result.ok( + this.values.equals(values) + ? this + : new AssetInfrastructure( + { + ...this.props, + history: [ + ...this.history, + InfrastructureRevision.create({ values, actorId, recordedAt }), + ], + }, + this.id + ) + ); + } +} diff --git a/modules/assets/src/api/domain/infrastructure-interface.entity.ts b/modules/assets/src/api/domain/infrastructure-interface.entity.ts new file mode 100644 index 0000000..43edc85 --- /dev/null +++ b/modules/assets/src/api/domain/infrastructure-interface.entity.ts @@ -0,0 +1,36 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import { DomainEntity } from "@repo/rdx-ddd"; + +import type { InfrastructureData } from "./infrastructure-values"; +import type { InterfaceAddress } from "./interface-address.entity"; + +type InterfaceProps = Readonly<{ + name: string; + kind: InfrastructureData["interfaces"][number]["kind"]; + mac: string; + notes: string; + addresses: readonly InterfaceAddress[]; +}>; + +export class InfrastructureInterface extends DomainEntity { + static rehydrate(id: UniqueID, props: InterfaceProps) { + return new InfrastructureInterface( + Object.freeze({ ...props, addresses: Object.freeze([...props.addresses]) }), + id + ); + } + + toPrimitive(): InfrastructureData["interfaces"][number] { + return { + id: this.id.toPrimitive(), + name: this.props.name, + kind: this.props.kind, + mac: this.props.mac, + notes: this.props.notes, + addresses: this.props.addresses.map((item) => ({ + id: item.id.toPrimitive(), + address: item.address, + })), + }; + } +} diff --git a/modules/assets/src/api/domain/infrastructure-revision.entity.ts b/modules/assets/src/api/domain/infrastructure-revision.entity.ts new file mode 100644 index 0000000..30a9ed4 --- /dev/null +++ b/modules/assets/src/api/domain/infrastructure-revision.entity.ts @@ -0,0 +1,32 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import { DomainEntity } from "@repo/rdx-ddd"; + +import type { InfrastructureValues } from "./value-objects/infrastructure-values.vo"; + +type RevisionProps = Readonly<{ + values: InfrastructureValues; + actorId: UniqueID; + recordedAt: string; +}>; + +export class InfrastructureRevision extends DomainEntity { + static create(props: RevisionProps) { + return new InfrastructureRevision(Object.freeze(props)); + } + + static rehydrate(id: UniqueID, props: RevisionProps) { + return new InfrastructureRevision(Object.freeze(props), id); + } + + get values() { + return this.props.values; + } + + get actorId() { + return this.props.actorId; + } + + get recordedAt() { + return this.props.recordedAt; + } +} diff --git a/modules/assets/src/api/domain/infrastructure-values.ts b/modules/assets/src/api/domain/infrastructure-values.ts new file mode 100644 index 0000000..81cbfd6 --- /dev/null +++ b/modules/assets/src/api/domain/infrastructure-values.ts @@ -0,0 +1,51 @@ +import { z } from "zod/v4"; + +export const InfrastructureRoleSchema = z.enum([ + "network", + "server", + "storage", + "shared_peripheral", +]); + +export const InterfaceAddressInputSchema = z.strictObject({ + id: z.uuid().optional(), + address: z + .union([z.ipv4(), z.ipv6()]) + .transform((value) => + value.includes(":") ? new URL(`http://[${value}]`).hostname.slice(1, -1) : value + ), +}); + +export const InfrastructureInterfaceInputSchema = z.strictObject({ + id: z.uuid().optional(), + name: z.string().trim().min(1).max(100), + kind: z.enum(["ethernet", "wifi", "other"]), + mac: z + .string() + .trim() + .max(17) + .refine((value) => !value || /^(?:[0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test(value)) + .transform((value) => value.replaceAll("-", ":").toUpperCase()), + notes: z.string().trim().max(2000), + addresses: z.array(InterfaceAddressInputSchema).max(32), +}); + +export const InfrastructureInputSchema = z.strictObject({ + roles: z.array(InfrastructureRoleSchema).max(4), + interfaces: z.array(InfrastructureInterfaceInputSchema).max(32), +}); + +export const InfrastructureValuesSchema = InfrastructureInputSchema.extend({ + interfaces: z + .array( + InfrastructureInterfaceInputSchema.extend({ + id: z.uuid(), + addresses: z.array(InterfaceAddressInputSchema.extend({ id: z.uuid() })).max(32), + }) + ) + .max(32), +}); + +export type InfrastructureInput = z.infer; + +export type InfrastructureData = z.infer; diff --git a/modules/assets/src/api/domain/infrastructure.test.ts b/modules/assets/src/api/domain/infrastructure.test.ts new file mode 100644 index 0000000..9d8f529 --- /dev/null +++ b/modules/assets/src/api/domain/infrastructure.test.ts @@ -0,0 +1,177 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { UniqueID } from "@repo/rdx-ddd"; + +import { AssetInfrastructure } from "./asset-infrastructure.aggregate"; +import type { InfrastructureInput } from "./infrastructure-values"; +import { InfrastructureValues } from "./value-objects/infrastructure-values.vo"; + +function input(): InfrastructureInput { + return { + roles: ["shared_peripheral", "network"], + interfaces: [ + { + name: "Ethernet", + kind: "ethernet", + mac: "aa-bb-cc-dd-ee-ff", + notes: " Red local ", + addresses: [{ address: "192.168.5.201" }, { address: "2001:0DB8:0:0::1" }], + }, + { name: "Wi-Fi", kind: "wifi", mac: "", notes: "", addresses: [] }, + ], + }; +} + +test("infraestructura: interfaces independientes, varias IP, normalización y UUID v7", () => { + const source = input(); + const result = InfrastructureValues.create(source); + + assert.ok(result.isSuccess); + const values = result.data.toPrimitive(); + + assert.equal(values.interfaces[0]?.mac, "AA:BB:CC:DD:EE:FF"); + assert.equal(values.interfaces[0]?.notes, "Red local"); + assert.equal(values.interfaces[0]?.addresses[1]?.address, "2001:db8::1"); + assert.deepEqual(values.interfaces[1]?.addresses, []); + for (const item of values.interfaces) { + assert.equal(item.id[14], "7"); + for (const address of item.addresses) { + assert.equal(address.id[14], "7"); + } + } + assert.equal(source.interfaces[0]?.mac, "aa-bb-cc-dd-ee-ff"); + values.roles.push("server"); + assert.equal(result.data.roles.includes("server"), false); +}); + +test("infraestructura: mismo contenido no cambia identidades ni añade revisiones", () => { + const first = InfrastructureValues.create(input()); + + assert.ok(first.isSuccess); + const actor = UniqueID.generateNewID(); + const created = AssetInfrastructure.create( + UniqueID.generateNewID(), + UniqueID.generateNewID(), + first.data, + actor, + "2026-10-17T10:00:00.000Z" + ); + + assert.ok(created.isSuccess); + const same = InfrastructureValues.create(input(), first.data); + + assert.ok(same.isSuccess); + assert.deepEqual(same.data.toPrimitive(), first.data.toPrimitive()); + const unchanged = created.data.update(same.data, actor, "2026-10-17T11:00:00.000Z"); + + assert.ok(unchanged.isSuccess); + assert.equal(unchanged.data, created.data); + const draft = first.data.toPrimitive(); + const eth = draft.interfaces[0]; + + assert.ok(eth); + eth.name = "LAN principal"; + eth.addresses = [ + { + address: "192.168.5.202", + id: eth.addresses[0]?.id ?? UniqueID.generateNewID().toPrimitive(), + }, + ]; + const changedValues = InfrastructureValues.create(draft, first.data); + + assert.ok(changedValues.isSuccess); + const changed = created.data.update(changedValues.data, actor, "2026-10-17T12:00:00.000Z"); + + assert.ok(changed.isSuccess); + assert.ok(changed.data.id.equals(created.data.id)); + assert.equal(changed.data.history.length, 2); + assert.equal( + changed.data.values.toPrimitive().interfaces[0]?.id, + first.data.toPrimitive().interfaces[0]?.id + ); + assert.equal( + changed.data.history[0]?.values.toPrimitive().interfaces[0]?.addresses[0]?.address, + "192.168.5.201" + ); +}); + +test("infraestructura: rechaza nombres/IP duplicados, valores inválidos e identidades ajenas", () => { + const base = input(); + const iface = base.interfaces[0]; + + assert.ok(iface); + const invalid: InfrastructureInput[] = [ + { roles: ["network", "network"], interfaces: [] }, + { roles: [], interfaces: [iface, { ...iface, name: " ETHERNET " }] }, + { + roles: [], + interfaces: [ + { ...iface, addresses: [{ address: "2001:db8::1" }, { address: "2001:0db8:0:0::1" }] }, + ], + }, + { roles: [], interfaces: [{ ...iface, addresses: [{ address: "192.168.5.201/24" }] }] }, + { roles: [], interfaces: [{ ...iface, addresses: [{ address: "999.1.1.1" }] }] }, + { roles: [], interfaces: [{ ...iface, mac: "not-a-mac" }] }, + { roles: [], interfaces: [{ ...iface, id: UniqueID.generateNewID().toPrimitive() }] }, + { + roles: [], + interfaces: [ + { + ...iface, + addresses: [{ address: "192.168.5.201", id: UniqueID.generateNewID().toPrimitive() }], + }, + ], + }, + ]; + + for (const value of invalid) { + assert.equal(InfrastructureValues.create(value).isSuccess, false); + } + const original = InfrastructureValues.create(base); + + assert.ok(original.isSuccess); + const stolen = original.data.toPrimitive(); + const address = stolen.interfaces[0]?.addresses[0]; + const wifi = stolen.interfaces[1]; + + assert.ok(address && wifi); + wifi.addresses.push(address); + assert.equal(InfrastructureValues.create(stolen, original.data).isSuccess, false); +}); + +test("infraestructura: límites y direcciones repetidas entre interfaces o compañías permitidas", () => { + const interfaces = Array.from({ length: 32 }, (_, index) => ({ + name: `LAN ${index}`, + kind: "ethernet" as const, + mac: "", + notes: "", + addresses: [{ address: "192.168.5.201" }], + })); + + assert.ok(InfrastructureValues.create({ roles: [], interfaces }).isSuccess); + assert.equal( + InfrastructureValues.create({ + roles: [], + interfaces: [ + ...interfaces, + { name: "LAN extra", kind: "other", mac: "", notes: "", addresses: [] }, + ], + }).isSuccess, + false + ); + assert.ok(InfrastructureValues.create({ roles: [], interfaces: [] }).isSuccess); + const other = InfrastructureValues.create(input()); + + assert.ok(other.isSuccess); + assert.equal( + AssetInfrastructure.create( + UniqueID.generateNewID(), + UniqueID.generateNewID(), + other.data, + UniqueID.generateNewID(), + "not-a-date" + ).isSuccess, + false + ); +}); diff --git a/modules/assets/src/api/domain/interface-address.entity.ts b/modules/assets/src/api/domain/interface-address.entity.ts new file mode 100644 index 0000000..f7c41b5 --- /dev/null +++ b/modules/assets/src/api/domain/interface-address.entity.ts @@ -0,0 +1,12 @@ +import { DomainEntity, type UniqueID } from "@repo/rdx-ddd"; + +/** Identidad estable de una dirección dentro de la interfaz y sus revisiones. */ +export class InterfaceAddress extends DomainEntity<{ address: string }> { + static rehydrate(id: UniqueID, address: string) { + return new InterfaceAddress({ address }, id); + } + + get address() { + return this.props.address; + } +} diff --git a/modules/assets/src/api/domain/value-objects/infrastructure-values.vo.ts b/modules/assets/src/api/domain/value-objects/infrastructure-values.vo.ts new file mode 100644 index 0000000..f6f1078 --- /dev/null +++ b/modules/assets/src/api/domain/value-objects/infrastructure-values.vo.ts @@ -0,0 +1,153 @@ +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { AssetCatalogError } from "../asset-definition"; +import { InfrastructureInterface } from "../infrastructure-interface.entity"; +import type { InfrastructureData, InfrastructureInput } from "../infrastructure-values"; +import { InfrastructureInputSchema, InfrastructureValuesSchema } from "../infrastructure-values"; +import { InterfaceAddress } from "../interface-address.entity"; + +export function infrastructureIdentity(value: string) { + const id = UniqueID.create(value); + + if (!id.isSuccess) { + throw new Error("Identidad de infraestructura persistida inválida."); + } + + return id.data; +} + +export class InfrastructureValues { + private constructor( + readonly roles: readonly InfrastructureData["roles"][number][], + readonly interfaces: readonly InfrastructureInterface[] + ) { + Object.freeze(this); + } + + static create(input: InfrastructureInput, previous?: InfrastructureValues) { + const parsed = InfrastructureInputSchema.safeParse(input); + + if (!parsed.success) { + return Result.fail(new AssetCatalogError("INVALID_REQUEST")); + } + const old = previous?.toPrimitive().interfaces ?? []; + const interfaces = parsed.data.interfaces.map((item) => { + const existing = item.id + ? old.find((entry) => entry.id === item.id) + : old.find( + (entry) => entry.name.toLocaleLowerCase("es") === item.name.toLocaleLowerCase("es") + ); + + return { + ...item, + id: item.id ?? existing?.id ?? UniqueID.generateNewID().toPrimitive(), + addresses: item.addresses.map((address) => ({ + ...address, + id: + address.id ?? + existing?.addresses.find((entry) => entry.address === address.address)?.id ?? + UniqueID.generateNewID().toPrimitive(), + })), + }; + }); + + if ( + interfaces.some((item) => { + const existing = old.find((entry) => entry.id === item.id); + + return ( + (parsed.data.interfaces.some((entry) => entry.id === item.id) && !existing) || + item.addresses.some( + (address) => + parsed.data.interfaces + .flatMap((entry) => entry.addresses) + .some((entry) => entry.id === address.id) && + !existing?.addresses.some((entry) => entry.id === address.id) + ) + ); + }) + ) { + return Result.fail(new AssetCatalogError("INVALID_REQUEST")); + } + + return InfrastructureValues.reconstruct({ roles: parsed.data.roles, interfaces }); + } + + static reconstruct(data: InfrastructureData) { + const parsed = InfrastructureValuesSchema.safeParse(data); + + if (!parsed.success) { + return Result.fail(new AssetCatalogError("INVALID_REQUEST")); + } + const values = parsed.data; + const names = values.interfaces.map((item) => item.name.toLocaleLowerCase("es")); + const ids = values.interfaces.flatMap((item) => [ + item.id, + ...item.addresses.map((address) => address.id), + ]); + + if ( + new Set(names).size !== names.length || + new Set(ids).size !== ids.length || + new Set(values.roles).size !== values.roles.length || + values.interfaces.some( + (item) => + new Set(item.addresses.map((address) => address.address)).size !== item.addresses.length + ) + ) { + return Result.fail(new AssetCatalogError("INVALID_REQUEST")); + } + + return Result.ok( + new InfrastructureValues( + Object.freeze([...values.roles].sort()), + Object.freeze( + values.interfaces.map((item) => + InfrastructureInterface.rehydrate(infrastructureIdentity(item.id), { + ...item, + addresses: item.addresses.map((address) => + InterfaceAddress.rehydrate(infrastructureIdentity(address.id), address.address) + ), + }) + ) + ) + ) + ); + } + + toPrimitive(): InfrastructureData { + return { + roles: [...this.roles], + interfaces: this.interfaces.map((item) => item.toPrimitive()), + }; + } + + equals(other: InfrastructureValues) { + const a = this.toPrimitive(); + const b = other.toPrimitive(); + + return ( + a.roles.join() === b.roles.join() && + a.interfaces.length === b.interfaces.length && + a.interfaces.every((item, index) => { + const next = b.interfaces[index]; + + return ( + next && + item.id === next.id && + item.name === next.name && + item.kind === next.kind && + item.mac === next.mac && + item.notes === next.notes && + item.addresses.length === next.addresses.length && + item.addresses.every( + (address, position) => + address.id === next.addresses[position]?.id && + address.address === next.addresses[position]?.address + ) + ); + }) + ); + } +} diff --git a/modules/assets/src/api/infrastructure/http/infrastructure.routes.ts b/modules/assets/src/api/infrastructure/http/infrastructure.routes.ts new file mode 100644 index 0000000..c771a9c --- /dev/null +++ b/modules/assets/src/api/infrastructure/http/infrastructure.routes.ts @@ -0,0 +1,84 @@ +import type { Application, RequestHandler, Response } from "express"; +import { Router } from "express"; +import { z } from "zod/v4"; + +import { + InfrastructureItemParamsSchema, + InfrastructureParamsSchema, + SaveInfrastructureRequestSchema, +} from "../../../common/infrastructure"; +import type { InfrastructureUseCases } from "../../application/infrastructure.use-cases"; +import { buildInfrastructureResponse } from "../../application/infrastructure-response.builder"; + +function fail(response: Response, code: string) { + response + .status(code.endsWith("NOT_FOUND") ? 404 : 400) + .json({ code, requestId: response.getHeader("x-request-id") }); +} + +export function registerInfrastructureRoutes( + app: Application, + service: InfrastructureUseCases, + authenticate: RequestHandler +) { + const router = Router(); + + router.get("/:companyId", (request, response, next) => { + const params = InfrastructureParamsSchema.safeParse(request.params); + + if (!(params.success && z.strictObject({}).safeParse(request.query).success)) { + fail(response, "INVALID_REQUEST"); + + return; + } + service + .get(params.data.companyId) + .then((result) => { + if (!result.isSuccess) { + fail(response, result.error.code); + + return; + } + response.json({ + items: result.data.items.map((item) => + buildInfrastructureResponse(item, result.data.actorNames) + ), + }); + }) + .catch(next); + }); + router.post("/:companyId/:assetId", (request, response, next) => { + const params = InfrastructureItemParamsSchema.safeParse(request.params); + const body = SaveInfrastructureRequestSchema.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; + } + service + .save(params.data.companyId, params.data.assetId, body.data.values, actor.data.userId) + .then((result) => { + if (!result.isSuccess) { + fail(response, result.error.code); + + return; + } + response.json({ + item: buildInfrastructureResponse(result.data.item, result.data.actorNames), + }); + }) + .catch(next); + }); + app.use("/api/asset-infrastructure", authenticate, router); +} diff --git a/modules/assets/src/api/infrastructure/persistence/infrastructure-migration.ts b/modules/assets/src/api/infrastructure/persistence/infrastructure-migration.ts new file mode 100644 index 0000000..d4f7d5e --- /dev/null +++ b/modules/assets/src/api/infrastructure/persistence/infrastructure-migration.ts @@ -0,0 +1,91 @@ +import type { Sequelize } from "sequelize"; +import { DataTypes, QueryTypes } from "sequelize"; + +export async function migrateInfrastructure(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 id = { type: DataTypes.UUID, primaryKey: true }; + + const fk = (table: string) => ({ + type: DataTypes.UUID, + allowNull: false, + references: { model: table, key: "id" }, + onDelete: "RESTRICT", + }); + + const revision = () => ({ ...fk("asset_infrastructure_revisions"), primaryKey: true }); + + const definitions = [ + [ + "asset_infrastructure", + { id, companyId: fk("companies"), assetId: { ...fk("assets"), unique: true } }, + ], + [ + "asset_infrastructure_revisions", + { + id, + infrastructureId: fk("asset_infrastructure"), + actorId: fk("users"), + recordedAt: { type: DataTypes.STRING(30), allowNull: false }, + position: { type: DataTypes.INTEGER, allowNull: false }, + }, + ], + [ + "asset_infrastructure_roles", + { revisionId: revision(), role: { type: DataTypes.STRING(30), primaryKey: true } }, + ], + [ + "asset_interface_snapshots", + { + revisionId: revision(), + id, + name: { type: DataTypes.STRING(100), allowNull: false }, + kind: { type: DataTypes.STRING(12), allowNull: false }, + mac: { type: DataTypes.STRING(17), allowNull: false }, + notes: { type: DataTypes.TEXT, allowNull: false }, + position: { type: DataTypes.INTEGER, allowNull: false }, + }, + ], + [ + "asset_interface_address_snapshots", + { + revisionId: revision(), + id, + interfaceId: { type: DataTypes.UUID, allowNull: false }, + address: { type: DataTypes.STRING(45), allowNull: false }, + position: { type: DataTypes.INTEGER, allowNull: false }, + }, + ], + ] as const; + + for (const [table, columns] of definitions) { + if (!tables.some((row) => row.name === table)) { + await query.createTable(table, columns); + } + } + const constraints = await database.query<{ name: string }>( + "SELECT CONSTRAINT_NAME AS name FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN ('asset_infrastructure','asset_interface_address_snapshots','asset_infrastructure_revisions')", + { type: QueryTypes.SELECT } + ); + + if (!constraints.some((row) => row.name === "infrastructure_asset_company")) { + await database.query( + "ALTER TABLE asset_infrastructure ADD CONSTRAINT infrastructure_asset_company FOREIGN KEY (companyId,assetId) REFERENCES assets(companyId,id)" + ); + } + if (!constraints.some((row) => row.name === "interface_address_owner")) { + await database.query( + "ALTER TABLE asset_interface_address_snapshots ADD CONSTRAINT interface_address_owner FOREIGN KEY (revisionId,interfaceId) REFERENCES asset_interface_snapshots(revisionId,id)" + ); + } + if (!constraints.some((row) => row.name === "infrastructure_revision_position")) { + await query.addConstraint("asset_infrastructure_revisions", { + fields: ["infrastructureId", "position"], + type: "unique", + name: "infrastructure_revision_position", + }); + } +} diff --git a/modules/assets/src/api/infrastructure/persistence/infrastructure-persistence.mapper.ts b/modules/assets/src/api/infrastructure/persistence/infrastructure-persistence.mapper.ts new file mode 100644 index 0000000..b76cc65 --- /dev/null +++ b/modules/assets/src/api/infrastructure/persistence/infrastructure-persistence.mapper.ts @@ -0,0 +1,100 @@ +import { z } from "zod/v4"; + +import { AssetInfrastructure } from "../../domain/asset-infrastructure.aggregate"; +import { InfrastructureRevision } from "../../domain/infrastructure-revision.entity"; +import { + InfrastructureRoleSchema, + InfrastructureValuesSchema, +} from "../../domain/infrastructure-values"; +import { + InfrastructureValues, + infrastructureIdentity, +} from "../../domain/value-objects/infrastructure-values.vo"; + +export const InfrastructureRootRowSchema = z.object({ + id: z.uuid(), + companyId: z.uuid(), + assetId: z.uuid(), +}); + +export const InfrastructureRevisionRowSchema = z.object({ + id: z.uuid(), + infrastructureId: z.uuid(), + actorId: z.uuid(), + recordedAt: z.iso.datetime(), + position: z.number().int().nonnegative(), +}); + +export const InfrastructureRoleRowSchema = z.object({ + revisionId: z.uuid(), + role: InfrastructureRoleSchema, +}); + +export const InterfaceRowSchema = InfrastructureValuesSchema.shape.interfaces.element + .omit({ addresses: true }) + .extend({ revisionId: z.uuid(), position: z.number().int().nonnegative() }); + +export const AddressRowSchema = + InfrastructureValuesSchema.shape.interfaces.element.shape.addresses.element.extend({ + revisionId: z.uuid(), + interfaceId: z.uuid(), + position: z.number().int().nonnegative(), + }); + +export class InfrastructurePersistenceMapper { + toDomain( + root: z.infer, + revisions: z.infer[], + roles: z.infer[], + interfaces: z.infer[], + addresses: z.infer[] + ) { + const history = revisions + .filter((row) => row.infrastructureId === root.id) + .sort((a, b) => a.position - b.position) + .map((row, index) => { + if (row.position !== index) { + throw new Error("Secuencia de infraestructura inconsistente."); + } + const values = InfrastructureValues.reconstruct({ + roles: roles.filter((role) => role.revisionId === row.id).map((role) => role.role), + interfaces: interfaces + .filter((item) => item.revisionId === row.id) + .sort((a, b) => a.position - b.position) + .map((item) => ({ + id: item.id, + name: item.name, + kind: item.kind, + mac: item.mac, + notes: item.notes, + addresses: addresses + .filter( + (address) => address.revisionId === row.id && address.interfaceId === item.id + ) + .sort((a, b) => a.position - b.position) + .map((address) => ({ id: address.id, address: address.address })), + })), + }); + + if (!values.isSuccess) { + throw new Error("Valores de infraestructura persistidos inválidos."); + } + + return InfrastructureRevision.rehydrate(infrastructureIdentity(row.id), { + values: values.data, + actorId: infrastructureIdentity(row.actorId), + recordedAt: row.recordedAt, + }); + }); + + if (!history.length) { + throw new Error("Infraestructura persistida sin historial."); + } + + return AssetInfrastructure.rehydrate(infrastructureIdentity(root.id), { + companyId: infrastructureIdentity(root.companyId), + assetId: infrastructureIdentity(root.assetId), + history, + }); + } +} diff --git a/modules/assets/src/api/infrastructure/persistence/remove-asset-infrastructure.ts b/modules/assets/src/api/infrastructure/persistence/remove-asset-infrastructure.ts new file mode 100644 index 0000000..6c04418 --- /dev/null +++ b/modules/assets/src/api/infrastructure/persistence/remove-asset-infrastructure.ts @@ -0,0 +1,30 @@ +import type { Sequelize, Transaction } from "sequelize"; + +/** Datos propios del activo: su borrado acompaña a la eliminación del inventario en la misma transacción. */ +export async function removeAssetInfrastructure( + database: Sequelize, + companyId: string, + assetId: string, + transaction: Transaction +) { + const replacements = { companyId, assetId }; + + for (const table of [ + "asset_interface_address_snapshots", + "asset_interface_snapshots", + "asset_infrastructure_roles", + ]) { + await database.query( + `DELETE s FROM ${table} s JOIN asset_infrastructure_revisions r ON r.id=s.revisionId JOIN asset_infrastructure i ON i.id=r.infrastructureId WHERE i.companyId=:companyId AND i.assetId=:assetId`, + { replacements, transaction } + ); + } + await database.query( + "DELETE r FROM asset_infrastructure_revisions r JOIN asset_infrastructure i ON i.id=r.infrastructureId WHERE i.companyId=:companyId AND i.assetId=:assetId", + { replacements, transaction } + ); + await database.query( + "DELETE FROM asset_infrastructure WHERE companyId=:companyId AND assetId=:assetId", + { replacements, transaction } + ); +} diff --git a/modules/assets/src/api/infrastructure/persistence/sequelize-asset.repository.ts b/modules/assets/src/api/infrastructure/persistence/sequelize-asset.repository.ts index 7b49d0a..e20f17b 100644 --- a/modules/assets/src/api/infrastructure/persistence/sequelize-asset.repository.ts +++ b/modules/assets/src/api/infrastructure/persistence/sequelize-asset.repository.ts @@ -16,6 +16,7 @@ import { sqlIdentifier } from "./asset-relational-sql"; import { RelationshipModel } from "./asset-relationship.model"; import { AssetRelationshipPersistenceMapper } from "./asset-relationship-persistence.mapper"; import { loadWorkstations } from "./load-workstations"; +import { removeAssetInfrastructure } from "./remove-asset-infrastructure"; import { readBrands } from "./sequelize-brand.repository"; import { WorkstationAssetModel } from "./workstation.model"; import { WorkstationPersistenceMapper } from "./workstation-persistence.mapper"; @@ -244,6 +245,7 @@ export class SequelizeAssetRepository implements IAssetRepository { { type: QueryTypes.SELECT, transaction } ); const ownedTables = new Set([ + "asset_infrastructure", "asset_characteristics", "asset_revision_characteristics", "asset_revisions", @@ -270,6 +272,7 @@ export class SequelizeAssetRepository implements IAssetRepository { } private async removeInventory(assetId: string, companyId: string, transaction: Transaction) { + await removeAssetInfrastructure(this.database, companyId, assetId, transaction); for (const table of [ "asset_revision_characteristics", "asset_characteristics", diff --git a/modules/assets/src/api/infrastructure/persistence/sequelize-infrastructure.repository.ts b/modules/assets/src/api/infrastructure/persistence/sequelize-infrastructure.repository.ts new file mode 100644 index 0000000..1cf1168 --- /dev/null +++ b/modules/assets/src/api/infrastructure/persistence/sequelize-infrastructure.repository.ts @@ -0,0 +1,141 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import type { Sequelize, Transaction } from "sequelize"; +import { QueryTypes } from "sequelize"; +import { z } from "zod/v4"; + +import type { IInfrastructureRepository } from "../../application/infrastructure.repository"; +import { AssetCatalogError } from "../../domain/asset-definition"; + +import { writeAssetRow } from "./asset-relational-sql"; +import { + AddressRowSchema, + InfrastructurePersistenceMapper, + InfrastructureRevisionRowSchema, + InfrastructureRoleRowSchema, + InfrastructureRootRowSchema, + InterfaceRowSchema, +} from "./infrastructure-persistence.mapper"; +import { writeInfrastructureRevision } from "./write-infrastructure-revision"; + +export class SequelizeInfrastructureRepository implements IInfrastructureRepository { + constructor(private readonly database: Sequelize) {} + + private async load(company: UniqueID, transaction: Transaction, lock = false) { + const companies = await this.database.query<{ id: string }>( + `SELECT id FROM companies WHERE id=:id${lock ? " FOR UPDATE" : ""}`, + { replacements: { id: company.toPrimitive() }, type: QueryTypes.SELECT, transaction } + ); + + if (!companies.length) { + return Result.fail(new AssetCatalogError("COMPANY_NOT_FOUND")); + } + + const select = async (table: string) => + this.database.query( + `SELECT t.* FROM ${table} t JOIN asset_infrastructure_revisions r ON r.id=t.revisionId JOIN asset_infrastructure i ON i.id=r.infrastructureId WHERE i.companyId=:companyId`, + { replacements: { companyId: company.toPrimitive() }, type: QueryTypes.SELECT, transaction } + ); + + const roots = z.array(InfrastructureRootRowSchema).parse( + await this.database.query("SELECT * FROM asset_infrastructure WHERE companyId=:companyId", { + replacements: { companyId: company.toPrimitive() }, + type: QueryTypes.SELECT, + transaction, + }) + ); + const revisions = z.array(InfrastructureRevisionRowSchema).parse( + await this.database.query( + "SELECT r.* FROM asset_infrastructure_revisions r JOIN asset_infrastructure i ON i.id=r.infrastructureId WHERE i.companyId=:companyId", + { + replacements: { companyId: company.toPrimitive() }, + type: QueryTypes.SELECT, + transaction, + } + ) + ); + const roles = z + .array(InfrastructureRoleRowSchema) + .parse(await select("asset_infrastructure_roles")); + const interfaces = z.array(InterfaceRowSchema).parse(await select("asset_interface_snapshots")); + const addresses = z + .array(AddressRowSchema) + .parse(await select("asset_interface_address_snapshots")); + const actors = z.array(z.object({ id: z.uuid(), name: z.string() })).parse( + await this.database.query("SELECT id,name FROM users", { + type: QueryTypes.SELECT, + transaction, + }) + ); + const mapper = new InfrastructurePersistenceMapper(); + + return Result.ok({ + items: roots.map((root) => mapper.toDomain(root, revisions, roles, interfaces, addresses)), + actorNames: new Map(actors.map((actor) => [actor.id, actor.name])), + }); + } + + get(company: UniqueID) { + return this.database.transaction((transaction) => this.load(company, transaction)); + } + + save( + company: UniqueID, + asset: UniqueID, + change: Parameters[2] + ) { + return this.database.transaction(async (transaction) => { + const loaded = await this.load(company, transaction, true); + + if (!loaded.isSuccess) { + return Result.fail(loaded.error); + } + const assets = await this.database.query<{ id: string }>( + "SELECT id FROM assets WHERE companyId=:companyId AND id=:assetId FOR UPDATE", + { + replacements: { companyId: company.toPrimitive(), assetId: asset.toPrimitive() }, + type: QueryTypes.SELECT, + transaction, + } + ); + + if (!assets.length) { + return Result.fail(new AssetCatalogError("ASSET_NOT_FOUND")); + } + const previous = loaded.data.items.find((item) => item.assetId.equals(asset)); + const changed = change(previous); + + if (!changed.isSuccess) { + return Result.fail(changed.error); + } + const item = changed.data; + + if (!previous) { + await writeAssetRow( + this.database, + "asset_infrastructure", + { + id: item.id.toPrimitive(), + companyId: company.toPrimitive(), + assetId: asset.toPrimitive(), + }, + transaction + ); + } + for (const [position, revision] of item.history.entries()) { + if (previous?.history.some((old) => old.id.equals(revision.id))) { + continue; + } + await writeInfrastructureRevision( + this.database, + item.id.toPrimitive(), + revision, + position, + transaction + ); + } + + return Result.ok({ item, actorNames: loaded.data.actorNames }); + }); + } +} diff --git a/modules/assets/src/api/infrastructure/persistence/write-infrastructure-revision.ts b/modules/assets/src/api/infrastructure/persistence/write-infrastructure-revision.ts new file mode 100644 index 0000000..f686d38 --- /dev/null +++ b/modules/assets/src/api/infrastructure/persistence/write-infrastructure-revision.ts @@ -0,0 +1,64 @@ +import type { Sequelize, Transaction } from "sequelize"; + +import type { InfrastructureRevision } from "../../domain/infrastructure-revision.entity"; + +import { writeAssetRow } from "./asset-relational-sql"; + +/** Solo inserta las instantáneas nuevas; el repositorio conserva las anteriores. */ +export async function writeInfrastructureRevision( + database: Sequelize, + infrastructureId: string, + revision: InfrastructureRevision, + position: number, + transaction: Transaction +) { + const revisionId = revision.id.toPrimitive(); + + await writeAssetRow( + database, + "asset_infrastructure_revisions", + { + id: revisionId, + infrastructureId: infrastructureId, + actorId: revision.actorId.toPrimitive(), + recordedAt: revision.recordedAt, + position, + }, + transaction + ); + const values = revision.values.toPrimitive(); + + for (const role of values.roles) { + await writeAssetRow(database, "asset_infrastructure_roles", { revisionId, role }, transaction); + } + for (const [index, entry] of values.interfaces.entries()) { + await writeAssetRow( + database, + "asset_interface_snapshots", + { + revisionId, + id: entry.id, + name: entry.name, + kind: entry.kind, + mac: entry.mac, + notes: entry.notes, + position: index, + }, + transaction + ); + for (const [addressPosition, address] of entry.addresses.entries()) { + await writeAssetRow( + database, + "asset_interface_address_snapshots", + { + revisionId, + id: address.id, + interfaceId: entry.id, + address: address.address, + position: addressPosition, + }, + transaction + ); + } + } +} diff --git a/modules/assets/src/api/module.ts b/modules/assets/src/api/module.ts index f87654c..4ff43dc 100644 --- a/modules/assets/src/api/module.ts +++ b/modules/assets/src/api/module.ts @@ -8,6 +8,7 @@ import { AssetUseCases } from "./application/asset.use-cases"; import { AssetCatalogUseCases } from "./application/asset-catalog.use-cases"; import { AssetRelationshipUseCases } from "./application/asset-relationship.use-cases"; import { BrandUseCases } from "./application/brand.use-cases"; +import { InfrastructureUseCases } from "./application/infrastructure.use-cases"; import { SupportUseCases } from "./application/support.use-cases"; import { WorkstationUseCases } from "./application/workstation.use-cases"; import { seedAssetCatalog } from "./domain/asset-catalog-seed"; @@ -15,6 +16,7 @@ import { registerAssetRoutes } from "./infrastructure/http/asset.routes"; import { registerAssetCatalogRoutes } from "./infrastructure/http/asset-catalog.routes"; import { registerRelationshipRoutes } from "./infrastructure/http/asset-relationship.routes"; import { registerBrandRoutes } from "./infrastructure/http/brand.routes"; +import { registerInfrastructureRoutes } from "./infrastructure/http/infrastructure.routes"; import { registerSupportRoutes } from "./infrastructure/http/support.routes"; import { registerWorkstationRoutes } from "./infrastructure/http/workstation.routes"; import { migrateInventory } from "./infrastructure/persistence/asset.model"; @@ -31,9 +33,11 @@ import { import { initializeAssetInventorySchema } from "./infrastructure/persistence/asset-relational-schema"; import { migrateRelationships } from "./infrastructure/persistence/asset-relationship.model"; import { migrateBrands } from "./infrastructure/persistence/brand-migration"; +import { migrateInfrastructure } from "./infrastructure/persistence/infrastructure-migration"; import { SequelizeAssetRepository } from "./infrastructure/persistence/sequelize-asset.repository"; import { SequelizeAssetCatalogRepository } from "./infrastructure/persistence/sequelize-asset-catalog.repository"; import { SequelizeBrandRepository } from "./infrastructure/persistence/sequelize-brand.repository"; +import { SequelizeInfrastructureRepository } from "./infrastructure/persistence/sequelize-infrastructure.repository"; import { SequelizeSupportRepository } from "./infrastructure/persistence/sequelize-support.repository"; import { migrateSupport } from "./infrastructure/persistence/support-migration"; import { migrateWorkstations } from "./infrastructure/persistence/workstation.model"; @@ -65,6 +69,10 @@ export async function createAssetModule(database: Sequelize, authenticate: Reque await initializeAssetInventorySchema(database); await enforceAssetRelationalConstraints(database); await migrateSupport(database); + await migrateInfrastructure(database); + const infrastructure = new InfrastructureUseCases( + new SequelizeInfrastructureRepository(database) + ); const support = new SupportUseCases(new SequelizeSupportRepository(database)); const assetRepository = new SequelizeAssetRepository(database); const inventory = new AssetUseCases(assetRepository); @@ -101,6 +109,7 @@ export async function createAssetModule(database: Sequelize, authenticate: Reque }, }, register: (app: Parameters[0]) => { + registerInfrastructureRoutes(app, infrastructure, authenticate); registerBrandRoutes(app, brands, authenticate); registerAssetCatalogRoutes(app, useCases, authenticate); registerAssetRoutes(app, inventory, authenticate); diff --git a/modules/assets/src/common/index.ts b/modules/assets/src/common/index.ts index 2ae9089..7416699 100644 --- a/modules/assets/src/common/index.ts +++ b/modules/assets/src/common/index.ts @@ -20,6 +20,7 @@ export const CatalogItemSchema = z.strictObject({ id: z.uuid(), companyId: z.uuid().nullable(), builtin: z.boolean(), + builtinKey: z.string().nullable().optional(), referenceCatalog: z.literal("brands").optional(), active: z.boolean(), effectiveActive: z.boolean(), @@ -39,11 +40,8 @@ export type CatalogResponseDTO = z.infer; export type SaveDefinitionResponseDTO = z.infer; export * from "./brands"; - +export * from "./infrastructure"; export * from "./inventory"; - export * from "./relationships"; - export * from "./support"; - export * from "./workstations"; diff --git a/modules/assets/src/common/infrastructure.ts b/modules/assets/src/common/infrastructure.ts new file mode 100644 index 0000000..389130a --- /dev/null +++ b/modules/assets/src/common/infrastructure.ts @@ -0,0 +1,47 @@ +import { z } from "zod/v4"; + +import { + InfrastructureInputSchema, + InfrastructureRoleSchema, + InfrastructureValuesSchema, +} from "../api/domain/infrastructure-values"; + +export type { InfrastructureData, InfrastructureInput } from "../api/domain/infrastructure-values"; + +export { InfrastructureInputSchema, InfrastructureRoleSchema }; + +export const InfrastructureParamsSchema = z.strictObject({ companyId: z.uuid() }); + +export const InfrastructureItemParamsSchema = InfrastructureParamsSchema.extend({ + assetId: z.uuid(), +}); + +export const SaveInfrastructureRequestSchema = z.strictObject({ + values: InfrastructureInputSchema, +}); + +export const InfrastructureSchema = z.strictObject({ + id: z.uuid(), + companyId: z.uuid(), + assetId: z.uuid(), + values: InfrastructureValuesSchema, + history: z + .array( + z.strictObject({ + id: z.uuid(), + values: InfrastructureValuesSchema, + actorId: z.uuid(), + actorName: z.string(), + recordedAt: z.iso.datetime(), + }) + ) + .min(1), +}); + +export const InfrastructureResponseSchema = z.strictObject({ + items: z.array(InfrastructureSchema), +}); + +export const SavedInfrastructureResponseSchema = z.strictObject({ item: InfrastructureSchema }); + +export type InfrastructureDTO = z.infer; diff --git a/modules/assets/src/common/locales/en.json b/modules/assets/src/common/locales/en.json index 5cbcfa2..7adca0c 100644 --- a/modules/assets/src/common/locales/en.json +++ b/modules/assets/src/common/locales/en.json @@ -475,5 +475,45 @@ "templateDescription": "Select the template that defines the asset characteristics.", "fieldsDescription": "Complete the template details. Fields marked with * are required.", "nameDescription": "Review the suggested name, technical status, site and registration date. Add notes if you need to provide more context." + }, + "infrastructure": { + "title": "Infrastructure", + "description": "Browse shared devices, interfaces, addresses and connections between assets.", + "edit": "Edit infrastructure", + "add": "Add to infrastructure", + "rolesTitle": "Device roles", + "roles": { + "network": "Network", + "server": "Server", + "storage": "Storage", + "shared_peripheral": "Shared peripheral" + }, + "interfaces": "Interfaces and addresses", + "connections": "Device connections", + "usage": "Where it is used", + "kind": "Interface type", + "kinds": { + "ethernet": "Ethernet", + "wifi": "Wi-Fi", + "other": "Other" + }, + "mac": "MAC address", + "addresses": "IP addresses", + "addAddress": "Add IP", + "addInterface": "Add interface", + "removeInterface": "Remove interface", + "interfacePosition": "Interface {{count}}", + "addressPosition": "IP address {{count}}", + "removeAddressPosition": "Remove IP address {{count}}", + "invalid": "Check the fields. Interface names must be unique and addresses cannot be repeated within an interface.", + "invalidIp": "Enter a valid IPv4 or IPv6 address without a mask.", + "invalidMac": "Enter a valid MAC such as AA:BB:CC:DD:EE:FF, or leave it blank.", + "emptyInterfaces": "No interfaces have been documented.", + "all": "All roles", + "helpTitle": "Interfaces and shared use", + "help": "Document the device roles and interfaces whose IP addresses you need. An interface may have no IP or several addresses.", + "examplesLabel": "Example", + "examples": "Printer with Ethernet 192.168.5.201 and Wi-Fi 192.168.5.202.", + "guidance": "Record device connections in Relationships. Port details remain in UniFi. This does not change characteristics or workstation assignments." } -} \ No newline at end of file +} diff --git a/modules/assets/src/common/locales/es.json b/modules/assets/src/common/locales/es.json index da479a2..bccc695 100644 --- a/modules/assets/src/common/locales/es.json +++ b/modules/assets/src/common/locales/es.json @@ -475,5 +475,45 @@ "templateDescription": "Selecciona la plantilla que define las características del activo.", "fieldsDescription": "Completa los datos de la plantilla. Los campos marcados con * son obligatorios.", "nameDescription": "Revisa el nombre propuesto, el estado técnico, la sede y la fecha de alta. Añade notas si necesitas aportar más contexto." + }, + "infrastructure": { + "title": "Infraestructura", + "description": "Consulta equipos compartidos, interfaces, direcciones y conexiones entre activos.", + "edit": "Editar infraestructura", + "add": "Añadir a infraestructura", + "rolesTitle": "Funciones del equipo", + "roles": { + "network": "Red", + "server": "Servidor", + "storage": "Almacenamiento", + "shared_peripheral": "Periférico compartido" + }, + "interfaces": "Interfaces y direcciones", + "connections": "Conexiones entre equipos", + "usage": "Dónde se utiliza", + "kind": "Tipo de interfaz", + "kinds": { + "ethernet": "Ethernet", + "wifi": "Wi-Fi", + "other": "Otra" + }, + "mac": "Dirección MAC", + "addresses": "Direcciones IP", + "addAddress": "Añadir IP", + "addInterface": "Añadir interfaz", + "removeInterface": "Quitar interfaz", + "interfacePosition": "Interfaz {{count}}", + "addressPosition": "Dirección IP {{count}}", + "removeAddressPosition": "Quitar dirección IP {{count}}", + "invalid": "Revisa los campos. Los nombres de interfaz deben ser únicos y no puede repetirse una IP dentro de la misma interfaz.", + "invalidIp": "Introduce una dirección IPv4 o IPv6 válida, sin máscara.", + "invalidMac": "Introduce una MAC válida, por ejemplo AA:BB:CC:DD:EE:FF, o deja el campo vacío.", + "emptyInterfaces": "No hay interfaces documentadas.", + "all": "Todas las funciones", + "helpTitle": "Interfaces y uso compartido", + "help": "Documenta las funciones del equipo y las interfaces cuya IP necesitas conocer. Una interfaz puede no tener IP o tener varias.", + "examplesLabel": "Ejemplo", + "examples": "Impresora con Ethernet 192.168.5.201 y Wi-Fi 192.168.5.202.", + "guidance": "Las conexiones entre equipos se registran en Relaciones. El detalle de puertos permanece en UniFi. No se modifican características ni asignaciones del puesto." } -} \ No newline at end of file +} diff --git a/modules/assets/src/web/assets/detail/ui/asset-detail.tsx b/modules/assets/src/web/assets/detail/ui/asset-detail.tsx index 502ae07..a19ad0f 100644 --- a/modules/assets/src/web/assets/detail/ui/asset-detail.tsx +++ b/modules/assets/src/web/assets/detail/ui/asset-detail.tsx @@ -5,6 +5,7 @@ import { FieldSections } from "@repo/rdx-ui/components/field-sections"; import { FormSectionTitle } from "@repo/rdx-ui/components/form-section-title"; import { useTranslation } from "react-i18next"; +import { AssetInfrastructureSections } from "../../../infrastructure/shared/ui/asset-infrastructure-sections"; import { formatAssetRegistrationDate } from "../../shared/helpers/format-asset-registration-date"; import { useAssetDetailController } from "../controllers"; import type { AssetDetailProps } from "../types"; @@ -122,6 +123,11 @@ export function AssetDetail(props: AssetDetailProps) {

{asset.values.notes}

)} + {children} {!compact && ( <> diff --git a/modules/assets/src/web/assets/detail/ui/asset-selection-panel.tsx b/modules/assets/src/web/assets/detail/ui/asset-selection-panel.tsx index 18ddd25..4054b69 100644 --- a/modules/assets/src/web/assets/detail/ui/asset-selection-panel.tsx +++ b/modules/assets/src/web/assets/detail/ui/asset-selection-panel.tsx @@ -75,6 +75,8 @@ export function AssetSelectionPanel({ ReactNode; sites: readonly { id: string; name: string; diff --git a/modules/assets/src/web/index.ts b/modules/assets/src/web/index.ts index 22cddb9..e2bdcbe 100644 --- a/modules/assets/src/web/index.ts +++ b/modules/assets/src/web/index.ts @@ -4,6 +4,11 @@ export { AssetSummaryCard } from "./assets/shared/ui/asset-summary-card"; export { BrandCatalogPage } from "./brands"; export { AssetCatalogPage } from "./catalog"; export { catalogApi, catalogKeys } from "./catalog/shared/api"; +export { InfrastructurePage } from "./infrastructure/list/ui/infrastructure-page"; +export { + infrastructureApi, + infrastructureKeys, +} from "./infrastructure/shared/api/infrastructure-api"; export { assetsEnglish, assetsSpanish } from "./shared"; export { inventoryApi } from "./shared/api"; export { inventoryKeys } from "./shared/queries"; diff --git a/modules/assets/src/web/infrastructure/list/ui/infrastructure-page.tsx b/modules/assets/src/web/infrastructure/list/ui/infrastructure-page.tsx new file mode 100644 index 0000000..4dbf86e --- /dev/null +++ b/modules/assets/src/web/infrastructure/list/ui/infrastructure-page.tsx @@ -0,0 +1,252 @@ +import { BrowserLink, useListQuery } from "@metis/core/client"; +import { + EntityListHeader, + EntityRowActions, + EntityStatus, + EntityTable, +} from "@repo/rdx-ui/components/entity-list"; +import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action"; +import { NetworkIcon, PencilIcon } from "@repo/rdx-ui/lib/icons"; +import { Button } from "@repo/shadcn-ui/components/button"; +import { useId, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { AssetSelect } from "../../../assets/select/ui/asset-select"; +import { catalogApi, catalogKeys } from "../../../catalog/shared/api"; +import { InventoryFeedback } from "../../../shared"; +import { inventoryApi } from "../../../shared/api"; +import { inventoryKeys } from "../../../shared/queries"; +import { infrastructureApi, infrastructureKeys } from "../../shared/api/infrastructure-api"; +import { infrastructureRoles } from "../../shared/helpers/infrastructure-roles"; +import { InfrastructureEditorDialog } from "../../shared/ui/infrastructure-editor-dialog"; + +export function InfrastructurePage({ companyId }: { companyId: string }) { + const { t, i18n } = useTranslation("assets"); + const prefix = useId(); + const [role, setRole] = useState(""); + const [editingId, setEditingId] = useState(""); + const inventory = useListQuery({ + queryKey: inventoryKeys.company(companyId), + queryFn: () => inventoryApi.get(companyId), + }); + const network = useListQuery({ + queryKey: infrastructureKeys.company(companyId), + queryFn: () => infrastructureApi.get(companyId), + }); + const catalog = useListQuery({ + queryKey: catalogKeys.scope(companyId), + queryFn: () => catalogApi.get(companyId), + }); + const assets = inventory.data?.assets ?? []; + const editing = assets.find((asset) => asset.id === editingId); + const items = assets.flatMap((asset) => { + const config = network.data?.items.find((item) => item.assetId === asset.id); + + const roles = infrastructureRoles(asset.values.templateId, catalog.data?.items ?? [], config); + + return (roles.length || config?.values.interfaces.length) && + (!role || roles.some((value) => value === role)) + ? [{ ...asset, config: { roles, interfaces: config?.values.interfaces ?? [] } }] + : []; + }); + + return ( +
+ ({ value: asset.id, label: asset.values.name }))} + placeholder={t("infrastructure.add")} + presentation="action" + required + value="" + /> + } + description={t("infrastructure.description")} + icon={} + title={t("infrastructure.title")} + /> + + {(inventory.isError || network.isError || catalog.isError) && ( + + )} + item.values.name, + render: (item) => ( + + {item.values.name} + + ), + }, + { + id: "roles", + label: t("infrastructure.rolesTitle"), + value: (item) => + item.config.roles.map((value) => t(`infrastructure.roles.${value}`)).join(" "), + render: (item) => + item.config.roles.map((value) => t(`infrastructure.roles.${value}`)).join(" · "), + }, + { + id: "interfaces", + label: t("infrastructure.interfaces"), + value: (item) => + item.config.interfaces + .map( + (entry) => + `${entry.name} ${entry.addresses.map((address) => address.address).join(" ")}` + ) + .join(" "), + render: (item) => + item.config.interfaces.length ? ( +
    + {item.config.interfaces.map((entry) => ( +
  • + {entry.name}:{" "} + {entry.addresses.map((address) => address.address).join(" · ") || "—"} +
  • + ))} +
+ ) : null, + }, + { + id: "connections", + label: t("infrastructure.connections"), + render: (item) => { + const links = + inventory.data?.relationships.filter( + (link) => + !link.endedAt && [link.values.sourceId, link.values.targetId].includes(item.id) + ) ?? []; + + return links.length ? ( +
    + {links.map((link) => { + const target = + link.values.sourceId === item.id + ? link.values.targetId + : link.values.sourceId; + + return ( +
  • + + {assets.find((asset) => asset.id === target)?.values.name ?? "—"} + {" "} + ·{" "} + {link.values.sourceId === item.id + ? link.values.name + : link.values.inverseName} +
  • + ); + })} +
+ ) : null; + }, + }, + { + id: "state", + label: t("technicalStatus"), + value: (item) => t(item.values.status), + render: (item) => ( + + {t(item.values.status)} + + ), + }, + { + id: "workstation", + label: t("workstationInAsset"), + render: (item) => { + const stations = + inventory.data?.workstations.filter( + (station) => station.values.active && station.values.assetIds.includes(item.id) + ) ?? []; + + return stations.length ? ( +
    + {stations.map((station) => ( +
  • + + {station.values.name} + +
  • + ))} +
+ ) : null; + }, + }, + { + id: "actions", + label: t("actions"), + render: (item) => ( +
+ ); +} diff --git a/modules/assets/src/web/infrastructure/shared/api/infrastructure-api.ts b/modules/assets/src/web/infrastructure/shared/api/infrastructure-api.ts new file mode 100644 index 0000000..dc29af5 --- /dev/null +++ b/modules/assets/src/web/infrastructure/shared/api/infrastructure-api.ts @@ -0,0 +1,42 @@ +import { csrfFetch } from "@metis/core/client"; +import { z } from "zod/v4"; + +import type { InfrastructureInput } from "../../../../common/infrastructure"; +import { + InfrastructureResponseSchema, + SavedInfrastructureResponseSchema, +} from "../../../../common/infrastructure"; +import { InventoryApiError } from "../../../shared/api/inventory-api-error"; + +export const infrastructureKeys = { company: (id: string) => ["asset-infrastructure", id] }; + +async function request(companyId: string, assetId?: string, values?: InfrastructureInput) { + const response = await csrfFetch( + `/api/asset-infrastructure/${companyId}${assetId ? `/${assetId}` : ""}`, + { + method: values ? "POST" : "GET", + credentials: "same-origin", + ...(values + ? { headers: { "content-type": "application/json" }, body: JSON.stringify({ values }) } + : {}), + } + ); + const body: unknown = await response.json(); + + if (!response.ok) { + const error = z.object({ code: z.string() }).safeParse(body); + + throw new InventoryApiError(error.success ? error.data.code : "INTERNAL_ERROR"); + } + + return body; +} + +export const infrastructureApi = { + async get(companyId: string) { + return InfrastructureResponseSchema.parse(await request(companyId)); + }, + async save(companyId: string, assetId: string, values: InfrastructureInput) { + return SavedInfrastructureResponseSchema.parse(await request(companyId, assetId, values)); + }, +}; diff --git a/modules/assets/src/web/infrastructure/shared/helpers/infrastructure-roles.ts b/modules/assets/src/web/infrastructure/shared/helpers/infrastructure-roles.ts new file mode 100644 index 0000000..d1caf2a --- /dev/null +++ b/modules/assets/src/web/infrastructure/shared/helpers/infrastructure-roles.ts @@ -0,0 +1,28 @@ +import type { CatalogItemDTO } from "../../../../common"; +import type { InfrastructureDTO, InfrastructureInput } from "../../../../common/infrastructure"; + +/** Las plantillas de serie usan su clave estable; las clasificaciones guardadas prevalecen. */ +export function infrastructureRoles( + templateId: string, + catalog: readonly CatalogItemDTO[], + current?: InfrastructureDTO +): InfrastructureInput["roles"] { + if (current) { + return current.values.roles; + } + const key = catalog.find((item) => item.id === templateId)?.builtinKey; + + switch (key) { + case "template:router": + case "template:firewall": + case "template:switch": + case "template:access_point": + return ["network"]; + case "template:server": + return ["server"]; + case "template:nas": + return ["storage"]; + default: + return []; + } +} diff --git a/modules/assets/src/web/infrastructure/shared/infrastructure.test.tsx b/modules/assets/src/web/infrastructure/shared/infrastructure.test.tsx new file mode 100644 index 0000000..b24986c --- /dev/null +++ b/modules/assets/src/web/infrastructure/shared/infrastructure.test.tsx @@ -0,0 +1,167 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { UniqueID } from "@repo/rdx-ddd"; +import { Maybe } from "@repo/rdx-utils"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createInstance } from "i18next"; +import { renderToStaticMarkup } from "react-dom/server"; +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 { InfrastructureValues } from "../../../api/domain/value-objects/infrastructure-values.vo"; +import { InfrastructureSchema } from "../../../common/infrastructure"; +import { AssetSchema } from "../../../common/inventory"; +import { catalogKeys } from "../../catalog/shared/api"; +import { assetsEnglish, assetsSpanish } from "../../shared"; +import { inventoryKeys } from "../../shared/queries"; +import { InfrastructurePage } from "../list/ui/infrastructure-page"; + +import { infrastructureKeys } from "./api/infrastructure-api"; +import { infrastructureRoles } from "./helpers/infrastructure-roles"; +import { AssetInfrastructurePanel } from "./ui/asset-infrastructure-panel"; + +const companyId = UniqueID.generateNewID().toPrimitive(); +const catalog = buildCatalogResponse(seedAssetCatalog(AssetCatalog.create()), Maybe.none()); +const router = catalog.items.find((item) => item.builtinKey === "template:router"); + +assert.ok(router && router.values.kind === "template"); +const templateId = router.id; +const values = { + name: "Router principal", + templateId, + siteId: null, + status: "operational", + notes: "", + characteristics: {}, +}; +const asset = AssetSchema.parse({ + id: UniqueID.generateNewID().toPrimitive(), + companyId, + values, + assigned: false, + displayStatus: "available", + specification: { + templateId, + templateName: "Router", + fields: [ + { + fieldId: UniqueID.generateNewID().toPrimitive(), + name: "Modelo", + type: "text", + unit: "", + choices: [], + required: false, + }, + ], + }, + history: [ + { + id: UniqueID.generateNewID().toPrimitive(), + values, + actorId: UniqueID.generateNewID().toPrimitive(), + actorName: "Técnico", + recordedAt: "2026-10-17T10:00:00.000Z", + }, + ], +}); +const networkValues = InfrastructureValues.create({ + roles: ["network"], + interfaces: [ + { + name: "LAN", + kind: "ethernet", + mac: "", + notes: "", + addresses: [{ address: "192.168.5.201" }], + }, + { name: "Wi-Fi", kind: "wifi", mac: "", notes: "", addresses: [{ address: "192.168.5.202" }] }, + ], +}); + +assert.ok(networkValues.isSuccess); +const snapshot = networkValues.data.toPrimitive(); +const config = InfrastructureSchema.parse({ + id: UniqueID.generateNewID().toPrimitive(), + assetId: asset.id, + companyId, + values: snapshot, + history: [ + { + id: UniqueID.generateNewID().toPrimitive(), + actorId: UniqueID.generateNewID().toPrimitive(), + actorName: "Técnico", + recordedAt: "2026-10-17T10:00:00.000Z", + values: snapshot, + }, + ], +}); + +test("infraestructura: clasificación de serie independiente del nombre/icono y selección manual prioritaria", () => { + assert.ok(router.values.kind === "template"); + const renamed = { + ...router, + values: { ...router.values, name: "Nombre propio", icon: "box" as const }, + }; + + assert.deepEqual(infrastructureRoles(templateId, [renamed]), ["network"]); + assert.deepEqual( + infrastructureRoles(templateId, [renamed], { + ...config, + values: { ...config.values, roles: ["storage"] }, + }), + ["storage"] + ); + assert.deepEqual(infrastructureRoles(templateId, [{ ...renamed, builtinKey: null }]), []); +}); + +test("infraestructura: listado y ficha presentan interfaces/IP en español e inglés, sin puertos", async () => { + for (const [language, title, edit] of [ + ["es", "Infraestructura", "Editar infraestructura"], + ["en", "Infrastructure", "Edit infrastructure"], + ] as const) { + const i18n = createInstance(); + + await i18n.init({ + lng: language, + resources: { es: { assets: assetsSpanish }, en: { assets: assetsEnglish } }, + }); + const client = new QueryClient(); + + client.setQueryData(catalogKeys.scope(companyId), catalog); + client.setQueryData(inventoryKeys.company(companyId), { + assets: [asset], + workstations: [], + relationships: [], + }); + client.setQueryData(infrastructureKeys.company(companyId), { items: [config] }); + try { + const html = renderToStaticMarkup( + + + + + + + ); + + assert.ok(html.includes(title)); + assert.ok(html.includes(edit)); + assert.ok(html.includes("192.168.5.201")); + assert.ok(html.includes("192.168.5.202")); + assert.ok(html.includes("Wi-Fi")); + assert.ok(html.includes(`/companies/${companyId}/assets/${asset.id}`)); + assert.ok(html.includes('aria-haspopup="dialog"')); + assert.equal(html.includes("infrastructure.roles.network"), false); + } finally { + client.clear(); + } + } +}); diff --git a/modules/assets/src/web/infrastructure/shared/ui/asset-infrastructure-panel.tsx b/modules/assets/src/web/infrastructure/shared/ui/asset-infrastructure-panel.tsx new file mode 100644 index 0000000..c4c7ac4 --- /dev/null +++ b/modules/assets/src/web/infrastructure/shared/ui/asset-infrastructure-panel.tsx @@ -0,0 +1,132 @@ +import { useListQuery } from "@metis/core/client"; +import { Button } from "@repo/shadcn-ui/components/button"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { catalogApi, catalogKeys } from "../../../catalog/shared/api"; +import { InventoryFeedback } from "../../../shared"; +import { infrastructureApi, infrastructureKeys } from "../api/infrastructure-api"; +import { infrastructureRoles } from "../helpers/infrastructure-roles"; + +import { InfrastructureEditorDialog } from "./infrastructure-editor-dialog"; + +export function AssetInfrastructurePanel({ + companyId, + assetId, + assetName, + templateId, +}: { + companyId: string; + assetId: string; + assetName: string; + templateId: string; +}) { + const { t, i18n } = useTranslation("assets"); + const [editing, setEditing] = useState(false); + const query = useListQuery({ + queryKey: infrastructureKeys.company(companyId), + queryFn: () => infrastructureApi.get(companyId), + }); + const catalog = useListQuery({ + queryKey: catalogKeys.scope(companyId), + queryFn: () => catalogApi.get(companyId), + }); + const current = query.data?.items.find((item) => item.assetId === assetId); + + return ( +
+ {query.isPending &&

{t("loading")}

} + + {(query.isError || catalog.isError) && ( + + )} + {current?.values.roles.length ? ( +

+ {current.values.roles.map((role) => t(`infrastructure.roles.${role}`)).join(" · ")} +

+ ) : null} + {query.isSuccess && !current?.values.interfaces.length && ( +

{t("infrastructure.emptyInterfaces")}

+ )} +
    + {current?.values.interfaces.map((entry) => ( +
  • +

    + {entry.name} · {t(`infrastructure.kinds.${entry.kind}`)} +

    +

    + {entry.addresses.length + ? entry.addresses.map((address) => address.address).join(" · ") + : "—"} +

    +

    + {t("infrastructure.mac")}: {entry.mac || "—"} +

    + {entry.notes &&

    {entry.notes}

    } +
  • + ))} +
+ + {current && ( +
+ + {t("history")} ({current.history.length}) + +
    + {[...current.history].reverse().map((revision) => ( +
  1. +

    + {new Intl.DateTimeFormat(i18n.language, { + dateStyle: "short", + timeStyle: "short", + }).format(new Date(revision.recordedAt))}{" "} + · {revision.actorName} +

    +

    + {revision.values.roles + .map((role) => t(`infrastructure.roles.${role}`)) + .join(" · ") || "—"} +

    +
      + {revision.values.interfaces.map((entry) => ( +
    • + {entry.name} · {t(`infrastructure.kinds.${entry.kind}`)} ·{" "} + {entry.addresses.map((address) => address.address).join(" · ") || "—"} ·{" "} + {entry.mac || "—"} + {entry.notes &&

      {entry.notes}

      } +
    • + ))} +
    +
  2. + ))} +
+
+ )} + {editing && ( + setEditing(false)} + /> + )} +
+ ); +} diff --git a/modules/assets/src/web/infrastructure/shared/ui/asset-infrastructure-sections.tsx b/modules/assets/src/web/infrastructure/shared/ui/asset-infrastructure-sections.tsx new file mode 100644 index 0000000..2cd6783 --- /dev/null +++ b/modules/assets/src/web/infrastructure/shared/ui/asset-infrastructure-sections.tsx @@ -0,0 +1,43 @@ +import { DetailSection } from "@repo/rdx-ui/components/detail-section"; +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; + +import type { AssetDTO } from "../../../../common/inventory"; + +import { AssetInfrastructurePanel } from "./asset-infrastructure-panel"; + +export function AssetInfrastructureSections({ + asset, + compact, + renderUsage, +}: { + asset: AssetDTO; + compact: boolean; + renderUsage: ((assetId: string) => ReactNode) | undefined; +}) { + const { t } = useTranslation("assets"); + + return ( + <> + + + + {renderUsage && ( + + {renderUsage(asset.id)} + + )} + + ); +} diff --git a/modules/assets/src/web/infrastructure/shared/ui/infrastructure-editor-dialog.tsx b/modules/assets/src/web/infrastructure/shared/ui/infrastructure-editor-dialog.tsx new file mode 100644 index 0000000..66aa3fb --- /dev/null +++ b/modules/assets/src/web/infrastructure/shared/ui/infrastructure-editor-dialog.tsx @@ -0,0 +1,145 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { FormActions, FormBody, FormDialog } from "@repo/rdx-ui/components/form-dialog"; +import { FormHelp } from "@repo/rdx-ui/components/form-help"; +import { FormSectionTitle } from "@repo/rdx-ui/components/form-section-title"; +import { Button } from "@repo/shadcn-ui/components/button"; +import { Checkbox } from "@repo/shadcn-ui/components/checkbox"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useId } from "react"; +import { useFieldArray, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; + +import type { InfrastructureDTO, InfrastructureInput } from "../../../../common/infrastructure"; +import { + InfrastructureInputSchema, + InfrastructureRoleSchema, +} from "../../../../common/infrastructure"; +import { InventoryFeedback } from "../../../shared"; +import { infrastructureApi, infrastructureKeys } from "../api/infrastructure-api"; + +import { InterfaceFields } from "./interface-fields"; + +export function InfrastructureEditorDialog({ + companyId, + assetId, + assetName, + current, + initialRoles = [], + onClose, +}: { + companyId: string; + assetId: string; + assetName: string; + current: InfrastructureDTO | undefined; + initialRoles?: InfrastructureInput["roles"]; + onClose: () => void; +}) { + const { t } = useTranslation("assets"); + const prefix = useId(); + const client = useQueryClient(); + const form = useForm({ + resolver: zodResolver(InfrastructureInputSchema), + defaultValues: current?.values ?? { roles: initialRoles, interfaces: [] }, + }); + const fields = useFieldArray({ control: form.control, name: "interfaces" }); + const roles = form.watch("roles"); + const mutation = useMutation({ + mutationFn: (values: InfrastructureInput) => infrastructureApi.save(companyId, assetId, values), + onSuccess: async () => { + await client.invalidateQueries({ queryKey: infrastructureKeys.company(companyId) }); + onClose(); + }, + }); + + return ( + { + if (!(open || mutation.isPending)) { + onClose(); + } + }} + open + size="large" + title={`${t("infrastructure.edit")}: ${assetName}`} + > +
mutation.mutate(values))} + > + +
+
+ {t("infrastructure.rolesTitle")} +
+ {InfrastructureRoleSchema.options.map((role) => ( + + ))} +
+
+
+ {t("infrastructure.interfaces")} + +
+ {fields.fields.map((field, index) => ( + fields.remove(index)} + /> + ))} + {form.formState.errors.interfaces && ( +

+ {t("infrastructure.invalid")} +

+ )} + +
+
+ + + + + +
+ ); +} diff --git a/modules/assets/src/web/infrastructure/shared/ui/interface-fields.tsx b/modules/assets/src/web/infrastructure/shared/ui/interface-fields.tsx new file mode 100644 index 0000000..544a555 --- /dev/null +++ b/modules/assets/src/web/infrastructure/shared/ui/interface-fields.tsx @@ -0,0 +1,139 @@ +import { FormLabel } from "@repo/rdx-ui/components/form-label"; +import { FormSectionTitle } from "@repo/rdx-ui/components/form-section-title"; +import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action"; +import { Button } from "@repo/shadcn-ui/components/button"; +import { Input } from "@repo/shadcn-ui/components/input"; +import { Textarea } from "@repo/shadcn-ui/components/textarea"; +import { useId } from "react"; +import type { UseFormReturn } from "react-hook-form"; +import { useFieldArray } from "react-hook-form"; +import { useTranslation } from "react-i18next"; + +import type { InfrastructureInput } from "../../../../common/infrastructure"; + +export function InterfaceFields({ + form, + index, + onRemove, + disabled, +}: { + form: UseFormReturn; + index: number; + onRemove: () => void; + disabled: boolean; +}) { + const { t } = useTranslation("assets"); + const prefix = useId(); + const addresses = useFieldArray({ control: form.control, name: `interfaces.${index}.addresses` }); + const errors = form.formState.errors.interfaces?.[index]; + + return ( +
+
+ + {t("infrastructure.interfacePosition", { count: index + 1 })} + + +
+
+
+ + {t("name")} + + +

{errors?.name && t("infrastructure.invalid")}

+
+
+ {t("infrastructure.kind")} + { + if (value === "ethernet" || value === "wifi" || value === "other") { + form.setValue(`interfaces.${index}.kind`, value, { shouldDirty: true }); + } + }} + options={["ethernet", "wifi", "other"].map((value) => ({ + value, + label: t(`infrastructure.kinds.${value}`), + }))} + placeholder={t("infrastructure.kind")} + value={form.watch(`interfaces.${index}.kind`)} + /> +
+
+
+ {t("infrastructure.mac")} + + {errors?.mac && ( +

+ {t("infrastructure.invalidMac")} +

+ )} +
+
+ + {t("infrastructure.addresses")} + + {addresses.fields.map((field, position) => ( +
+
+ + + + +
+ {errors?.addresses?.[position] && ( +

+ {t("infrastructure.invalidIp")} +

+ )} +
+ ))} + +
+
+ {t("notes")} +