Infraestructura

This commit is contained in:
David Arranz 2026-10-17 17:01:16 +02:00
parent c7fa4ad291
commit 4bfbb30be0
55 changed files with 3421 additions and 10 deletions

View File

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

View File

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

View File

@ -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<void>((resolve, reject) =>
server.close((error) => {
if (error) {
reject(error);
} else {
resolve();
}
})
);
}
});

View File

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

View File

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

View File

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

View File

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

View File

@ -10,7 +10,7 @@
},
"scripts": {
"typecheck": "tsc -p tsconfig.json",
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/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",

View File

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

View File

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

View File

@ -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<string, string> },
AssetCatalogError
>
>;
save(
company: UniqueID,
asset: UniqueID,
change: (
previous: AssetInfrastructure | undefined
) => Result<AssetInfrastructure, AssetCatalogError>
): Promise<
Result<
{ item: AssetInfrastructure; actorNames: ReadonlyMap<string, string> },
AssetCatalogError
>
>;
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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<typeof InfrastructureInputSchema>;
export type InfrastructureData = z.infer<typeof InfrastructureValuesSchema>;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<typeof InfrastructureRootRowSchema>,
revisions: z.infer<typeof InfrastructureRevisionRowSchema>[],
roles: z.infer<typeof InfrastructureRoleRowSchema>[],
interfaces: z.infer<typeof InterfaceRowSchema>[],
addresses: z.infer<typeof AddressRowSchema>[]
) {
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,
});
}
}

View File

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

View File

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

View File

@ -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<IInfrastructureRepository["save"]>[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 });
});
}
}

View File

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

View File

@ -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<typeof registerAssetCatalogRoutes>[0]) => {
registerInfrastructureRoutes(app, infrastructure, authenticate);
registerBrandRoutes(app, brands, authenticate);
registerAssetCatalogRoutes(app, useCases, authenticate);
registerAssetRoutes(app, inventory, authenticate);

View File

@ -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<typeof CatalogResponseSchema>;
export type SaveDefinitionResponseDTO = z.infer<typeof SaveDefinitionResponseSchema>;
export * from "./brands";
export * from "./infrastructure";
export * from "./inventory";
export * from "./relationships";
export * from "./support";
export * from "./workstations";

View File

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

View File

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

View File

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

View File

@ -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) {
<p className="whitespace-pre-wrap break-words text-sm">{asset.values.notes}</p>
</DetailSection>
)}
<AssetInfrastructureSections
asset={asset}
compact={compact}
renderUsage={lookups.renderUsage}
/>
{children}
{!compact && (
<>

View File

@ -75,6 +75,8 @@ export function AssetSelectionPanel({
<Accordion
defaultValue={[
"assignment",
"infrastructure",
"usage",
"characteristics",
"ungrouped",
"notes",

View File

@ -1,7 +1,10 @@
import type { ReactNode } from "react";
import type { CatalogItemDTO } from "../../../../common";
import type { AssetDTO } from "../../../../common/inventory";
export type InventoryLookups = {
renderUsage?: (assetId: string) => ReactNode;
sites: readonly {
id: string;
name: string;

View File

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

View File

@ -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 (
<div className="space-y-6">
<EntityListHeader
actions={
<AssetSelect
companyId={companyId}
disabled={!(inventory.isSuccess && network.isSuccess && catalog.isSuccess)}
id={`${prefix}-choose`}
label={t("infrastructure.add")}
onValueChange={setEditingId}
options={assets.map((asset) => ({ value: asset.id, label: asset.values.name }))}
placeholder={t("infrastructure.add")}
presentation="action"
required
value=""
/>
}
description={t("infrastructure.description")}
icon={<NetworkIcon />}
title={t("infrastructure.title")}
/>
<InventoryFeedback error={inventory.error ?? network.error ?? catalog.error} />
{(inventory.isError || network.isError || catalog.isError) && (
<Button
onClick={() => {
Promise.all([inventory.refetch(), network.refetch(), catalog.refetch()]).catch(
() => undefined
);
}}
variant="outline"
>
{t("retry")}
</Button>
)}
<EntityTable
columns={[
{
id: "name",
label: t("name"),
hideable: false,
value: (item) => item.values.name,
render: (item) => (
<BrowserLink href={`/companies/${companyId}/assets/${item.id}`}>
{item.values.name}
</BrowserLink>
),
},
{
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 ? (
<ul>
{item.config.interfaces.map((entry) => (
<li key={entry.id}>
{entry.name}:{" "}
{entry.addresses.map((address) => address.address).join(" · ") || "—"}
</li>
))}
</ul>
) : 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 ? (
<ul>
{links.map((link) => {
const target =
link.values.sourceId === item.id
? link.values.targetId
: link.values.sourceId;
return (
<li key={link.id}>
<BrowserLink href={`/companies/${companyId}/assets/${target}`}>
{assets.find((asset) => asset.id === target)?.values.name ?? "—"}
</BrowserLink>{" "}
·{" "}
{link.values.sourceId === item.id
? link.values.name
: link.values.inverseName}
</li>
);
})}
</ul>
) : null;
},
},
{
id: "state",
label: t("technicalStatus"),
value: (item) => t(item.values.status),
render: (item) => (
<EntityStatus active={item.values.status === "operational"}>
{t(item.values.status)}
</EntityStatus>
),
},
{
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 ? (
<ul>
{stations.map((station) => (
<li key={station.id}>
<BrowserLink href={`/companies/${companyId}/workstations/${station.id}`}>
{station.values.name}
</BrowserLink>
</li>
))}
</ul>
) : null;
},
},
{
id: "actions",
label: t("actions"),
render: (item) => (
<EntityRowActions
label={t("actions")}
primaryAction={{
label: t("infrastructure.edit"),
icon: <PencilIcon aria-hidden="true" />,
onClick: () => setEditingId(item.id),
}}
/>
),
},
]}
filterKey={role}
filters={
<SelectWithAction
id={`${prefix}-role`}
label={t("infrastructure.rolesTitle")}
onValueChange={setRole}
options={[
{ value: "", label: t("infrastructure.all") },
...["network", "server", "storage", "shared_peripheral"].map((value) => ({
value,
label: t(`infrastructure.roles.${value}`),
})),
]}
placeholder={t("infrastructure.all")}
value={role}
/>
}
filtersApplied={Boolean(role)}
items={items}
loading={inventory.isPending || network.isPending || catalog.isPending}
locale={i18n.language}
onClearFilters={() => setRole("")}
title={t("infrastructure.title")}
unavailable={
(!inventory.data && inventory.isError) ||
(!network.data && network.isError) ||
(!catalog.data && catalog.isError)
}
/>
{editing && (
<InfrastructureEditorDialog
assetId={editing.id}
assetName={editing.values.name}
companyId={companyId}
current={network.data?.items.find((item) => item.assetId === editing.id)}
initialRoles={infrastructureRoles(editing.values.templateId, catalog.data?.items ?? [])}
onClose={() => setEditingId("")}
/>
)}
</div>
);
}

View File

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

View File

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

View File

@ -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(
<QueryClientProvider client={client}>
<I18nextProvider i18n={i18n}>
<InfrastructurePage companyId={companyId} />
<AssetInfrastructurePanel
assetId={asset.id}
assetName={asset.values.name}
companyId={companyId}
templateId={templateId}
/>
</I18nextProvider>
</QueryClientProvider>
);
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();
}
}
});

View File

@ -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 (
<div className="space-y-3">
{query.isPending && <p role="status">{t("loading")}</p>}
<InventoryFeedback error={query.error ?? catalog.error} />
{(query.isError || catalog.isError) && (
<Button
onClick={() => {
Promise.all([query.refetch(), catalog.refetch()]).catch(() => undefined);
}}
size="sm"
variant="outline"
>
{t("retry")}
</Button>
)}
{current?.values.roles.length ? (
<p className="text-sm">
{current.values.roles.map((role) => t(`infrastructure.roles.${role}`)).join(" · ")}
</p>
) : null}
{query.isSuccess && !current?.values.interfaces.length && (
<p className="text-sm text-muted-foreground">{t("infrastructure.emptyInterfaces")}</p>
)}
<ul className="space-y-3">
{current?.values.interfaces.map((entry) => (
<li className="rounded-md border p-3 text-sm" key={entry.id}>
<p className="font-medium">
{entry.name} · {t(`infrastructure.kinds.${entry.kind}`)}
</p>
<p>
{entry.addresses.length
? entry.addresses.map((address) => address.address).join(" · ")
: "—"}
</p>
<p className="text-muted-foreground">
{t("infrastructure.mac")}: {entry.mac || "—"}
</p>
{entry.notes && <p className="whitespace-pre-wrap">{entry.notes}</p>}
</li>
))}
</ul>
<Button
disabled={!(query.isSuccess && catalog.isSuccess)}
onClick={() => setEditing(true)}
size="sm"
variant="outline"
>
{t("infrastructure.edit")}
</Button>
{current && (
<details>
<summary className="cursor-pointer text-sm font-medium">
{t("history")} ({current.history.length})
</summary>
<ol className="mt-3 space-y-3">
{[...current.history].reverse().map((revision) => (
<li className="space-y-2 rounded-md border p-3 text-sm" key={revision.id}>
<p>
{new Intl.DateTimeFormat(i18n.language, {
dateStyle: "short",
timeStyle: "short",
}).format(new Date(revision.recordedAt))}{" "}
· {revision.actorName}
</p>
<p>
{revision.values.roles
.map((role) => t(`infrastructure.roles.${role}`))
.join(" · ") || "—"}
</p>
<ul>
{revision.values.interfaces.map((entry) => (
<li key={entry.id}>
{entry.name} · {t(`infrastructure.kinds.${entry.kind}`)} ·{" "}
{entry.addresses.map((address) => address.address).join(" · ") || "—"} ·{" "}
{entry.mac || "—"}
{entry.notes && <p className="whitespace-pre-wrap">{entry.notes}</p>}
</li>
))}
</ul>
</li>
))}
</ol>
</details>
)}
{editing && (
<InfrastructureEditorDialog
assetId={assetId}
assetName={assetName}
companyId={companyId}
current={current}
initialRoles={infrastructureRoles(templateId, catalog.data?.items ?? [])}
onClose={() => setEditing(false)}
/>
)}
</div>
);
}

View File

@ -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 (
<>
<DetailSection
accordionValue={compact ? "infrastructure" : undefined}
title={t("infrastructure.interfaces")}
>
<AssetInfrastructurePanel
assetId={asset.id}
assetName={asset.values.name}
companyId={asset.companyId}
templateId={asset.values.templateId}
/>
</DetailSection>
{renderUsage && (
<DetailSection
accordionValue={compact ? "usage" : undefined}
title={t("infrastructure.usage")}
>
{renderUsage(asset.id)}
</DetailSection>
)}
</>
);
}

View File

@ -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<InfrastructureInput>({
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 (
<FormDialog
cancelLabel={t("cancel")}
formActions
layout="stable"
onOpenChange={(open) => {
if (!(open || mutation.isPending)) {
onClose();
}
}}
open
size="large"
title={`${t("infrastructure.edit")}: ${assetName}`}
>
<form
className="flex min-h-0 flex-1 flex-col overflow-hidden"
onSubmit={form.handleSubmit((values) => mutation.mutate(values))}
>
<FormBody>
<fieldset className="space-y-5" disabled={mutation.isPending}>
<div className="space-y-3">
<FormSectionTitle>{t("infrastructure.rolesTitle")}</FormSectionTitle>
<div className="flex flex-wrap gap-4">
{InfrastructureRoleSchema.options.map((role) => (
<label
className="inline-flex items-center gap-2 text-sm"
htmlFor={`${prefix}-${role}`}
key={role}
>
<Checkbox
checked={roles.includes(role)}
disabled={mutation.isPending}
id={`${prefix}-${role}`}
onCheckedChange={(checked) =>
form.setValue(
"roles",
checked ? [...roles, role] : roles.filter((item) => item !== role),
{ shouldDirty: true }
)
}
/>
{t(`infrastructure.roles.${role}`)}
</label>
))}
</div>
</div>
<div className="flex items-center justify-between gap-2">
<FormSectionTitle layout="inline">{t("infrastructure.interfaces")}</FormSectionTitle>
<Button
disabled={fields.fields.length >= 32}
onClick={() =>
fields.append({ name: "", kind: "ethernet", mac: "", notes: "", addresses: [] })
}
type="button"
variant="outline"
>
{t("infrastructure.addInterface")}
</Button>
</div>
{fields.fields.map((field, index) => (
<InterfaceFields
disabled={mutation.isPending}
form={form}
index={index}
key={field.id}
onRemove={() => fields.remove(index)}
/>
))}
{form.formState.errors.interfaces && (
<p className="text-sm text-destructive" role="alert">
{t("infrastructure.invalid")}
</p>
)}
<InventoryFeedback error={mutation.error} />
</fieldset>
</FormBody>
<FormHelp
description={t("infrastructure.help")}
examples={t("infrastructure.examples")}
examplesLabel={t("infrastructure.examplesLabel")}
guidance={t("infrastructure.guidance")}
title={t("infrastructure.helpTitle")}
/>
<FormActions cancelDisabled={mutation.isPending}>
<Button disabled={mutation.isPending} type="submit">
{t(mutation.isPending ? "saving" : "save")}
</Button>
</FormActions>
</form>
</FormDialog>
);
}

View File

@ -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<InfrastructureInput>;
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 (
<fieldset className="min-w-0 space-y-3 rounded-lg border p-4" disabled={disabled}>
<div className="flex flex-wrap items-center justify-between gap-2">
<FormSectionTitle layout="inline">
{t("infrastructure.interfacePosition", { count: index + 1 })}
</FormSectionTitle>
<Button onClick={onRemove} size="sm" type="button" variant="ghost">
{t("infrastructure.removeInterface")}
</Button>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<FormLabel htmlFor={`${prefix}-name`} required>
{t("name")}
</FormLabel>
<Input
id={`${prefix}-name`}
{...form.register(`interfaces.${index}.name`)}
aria-invalid={Boolean(errors?.name)}
/>
<p className="text-xs text-destructive">{errors?.name && t("infrastructure.invalid")}</p>
</div>
<div className="space-y-2">
<FormLabel htmlFor={`${prefix}-kind`}>{t("infrastructure.kind")}</FormLabel>
<SelectWithAction
disabled={disabled}
id={`${prefix}-kind`}
label={t("infrastructure.kind")}
onValueChange={(value) => {
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`)}
/>
</div>
</div>
<div className="space-y-2">
<FormLabel htmlFor={`${prefix}-mac`}>{t("infrastructure.mac")}</FormLabel>
<Input
id={`${prefix}-mac`}
{...form.register(`interfaces.${index}.mac`)}
aria-invalid={Boolean(errors?.mac)}
/>
{errors?.mac && (
<p className="text-xs text-destructive" role="alert">
{t("infrastructure.invalidMac")}
</p>
)}
</div>
<div className="space-y-2">
<FormSectionTitle as="h4" layout="inline">
{t("infrastructure.addresses")}
</FormSectionTitle>
{addresses.fields.map((field, position) => (
<div className="space-y-1" key={field.id}>
<div className="flex items-center gap-2">
<span
aria-hidden="true"
className="shrink-0 text-xs text-muted-foreground tabular-nums"
>
{position + 1}/{addresses.fields.length}
</span>
<label className="sr-only" htmlFor={`${prefix}-address-${position}`}>
{t("infrastructure.addressPosition", { count: position + 1 })}
</label>
<Input
id={`${prefix}-address-${position}`}
{...form.register(`interfaces.${index}.addresses.${position}.address`)}
aria-invalid={Boolean(errors?.addresses?.[position])}
/>
<Button
aria-label={t("infrastructure.removeAddressPosition", { count: position + 1 })}
onClick={() => addresses.remove(position)}
size="sm"
type="button"
variant="ghost"
>
×
</Button>
</div>
{errors?.addresses?.[position] && (
<p className="text-xs text-destructive" role="alert">
{t("infrastructure.invalidIp")}
</p>
)}
</div>
))}
<Button
disabled={addresses.fields.length >= 32}
onClick={() => addresses.append({ address: "" })}
size="sm"
type="button"
variant="outline"
>
{t("infrastructure.addAddress")}
</Button>
</div>
<div className="space-y-2">
<FormLabel htmlFor={`${prefix}-notes`}>{t("notes")}</FormLabel>
<Textarea id={`${prefix}-notes`} {...form.register(`interfaces.${index}.notes`)} />
</div>
</fieldset>
);
}

View File

@ -10,7 +10,7 @@
},
"scripts": {
"typecheck": "tsc -p tsconfig.json",
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/companies/shared/companies-page.test.tsx src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/api/domain/mailbox.test.ts src/web/access/shared/access.test.tsx src/web/mail-configurations/shared/mail.test.tsx src/api/domain/work-profile.test.ts src/web/work-profiles/shared/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printer-configurations/shared/printer.test.tsx src/api/domain/application-configuration.test.ts src/web/application-configurations/shared/application.test.tsx src/api/domain/certificate-configuration.test.ts src/web/certificate-configurations/shared/certificate.test.tsx src/web/work-environments/shared/work-environment.test.tsx src/api/domain/telework-authorization.test.ts src/web/telework-authorizations/shared/telework.test.tsx src/api/domain/remote-connection.test.ts src/api/domain/process.test.ts src/web/remote-connections/shared/remote.test.tsx src/web/processes/shared/processes.test.tsx src/web/shared/initials-avatar.test.tsx src/api/infrastructure/company-logo-processor.test.ts src/web/companies/shared/company-avatar.test.tsx src/web/shared/client-contracts.test.ts src/web/workstations/detail/helpers/workstation-relations.test.ts src/web/workstations/detail/ui/workstation-detail.test.tsx src/web/workspace/shared/workspace-routes.test.ts src/web/workspace/view/helpers/sidebar-preference.test.ts src/api/domain/software-installation.test.ts src/web/software-installations/shared/software.test.tsx"
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/companies/shared/companies-page.test.tsx src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/api/domain/mailbox.test.ts src/web/access/shared/access.test.tsx src/web/mail-configurations/shared/mail.test.tsx src/api/domain/work-profile.test.ts src/web/work-profiles/shared/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printer-configurations/shared/printer.test.tsx src/api/domain/application-configuration.test.ts src/web/application-configurations/shared/application.test.tsx src/api/domain/certificate-configuration.test.ts src/web/certificate-configurations/shared/certificate.test.tsx src/web/work-environments/shared/work-environment.test.tsx src/api/domain/telework-authorization.test.ts src/web/telework-authorizations/shared/telework.test.tsx src/api/domain/remote-connection.test.ts src/api/domain/process.test.ts src/web/remote-connections/shared/remote.test.tsx src/web/processes/shared/processes.test.tsx src/web/shared/initials-avatar.test.tsx src/api/infrastructure/company-logo-processor.test.ts src/web/companies/shared/company-avatar.test.tsx src/web/shared/client-contracts.test.ts src/web/infrastructure/shared/helpers/printer-usage.test.ts src/web/workstations/detail/helpers/workstation-relations.test.ts src/web/workstations/detail/ui/workstation-detail.test.tsx src/web/workspace/shared/workspace-routes.test.ts src/web/workspace/view/helpers/sidebar-preference.test.ts src/api/domain/software-installation.test.ts src/web/software-installations/shared/software.test.tsx"
},
"dependencies": {
"@metis/core": "workspace:*",

View File

@ -825,6 +825,15 @@
"showArchived": "Also show archived sites",
"delete": "Delete site",
"deleteDescription": "“{{name}}” will be permanently deleted. This is only allowed if it has no associated records, including historical references. If it is in use, you can archive it."
},
"infrastructureUsage": {
"error": "Printer usage could not be loaded.",
"empty": "There are no open printing configurations for this asset.",
"incomplete": "Some configurations have an unresolved profile. Review their history.",
"default": "Default",
"review": "Configuration needs review.",
"history": "Historical configurations ({{count}})",
"notice": "Usage documented through profiles; this is not real-time detected activity."
}
},
"access": {

View File

@ -825,6 +825,15 @@
"showArchived": "Mostrar también las sedes archivadas",
"delete": "Eliminar sede",
"deleteDescription": "Se eliminará «{{name}}» de forma permanente. Solo se permite si no tiene ningún registro asociado, incluidas referencias históricas. Si está en uso, puedes archivarla."
},
"infrastructureUsage": {
"error": "No se ha podido consultar el uso de la impresora.",
"empty": "No hay configuraciones de impresión abiertas para este activo.",
"incomplete": "Hay configuraciones cuyo perfil no se ha podido resolver. Revisa el historial.",
"default": "Predeterminada",
"review": "Configuración pendiente de revisión.",
"history": "Configuraciones históricas ({{count}})",
"notice": "Uso documentado a través de perfiles; no representa actividad detectada en tiempo real."
}
},
"access": {

View File

@ -14,6 +14,7 @@ import {
MailIcon,
MapPinIcon,
MonitorIcon,
NetworkIcon,
PersonIcon,
PrinterIcon,
WorkProfileIcon,
@ -116,6 +117,7 @@ export function CompanyDetail(props: CompanyDetailProps) {
["workstations", assetTranslation("workstations"), MonitorIcon],
["profiles", t("profiles.title"), WorkProfileIcon],
["inventory", assetTranslation("inventory"), BoxIcon],
["infrastructure", assetTranslation("infrastructure.title"), NetworkIcon],
["resources", resourceTranslation("inventory"), LayersIcon],
["departments", t("departments"), BriefcaseIcon],
["sites", t("sites"), MapPinIcon],

View File

@ -2,6 +2,7 @@ import { AssetInventoryPage, WorkstationsPage } from "@metis/assets/client";
import { useState } from "react";
import type { CompanyDTO } from "../../../../common";
import { AssetPrinterUsage } from "../../../infrastructure/shared/ui/asset-printer-usage";
import { WorkstationDetailPage } from "../../../workstations/detail/ui/workstation-detail-page";
import { InventoryQuickCreateDialog } from "./inventory-quick-create-dialog";
@ -24,6 +25,7 @@ export function CompanyInventory({
onCreated: (id: string) => void;
}>();
const lookups = {
renderUsage: (assetId: string) => <AssetPrinterUsage assetId={assetId} company={company} />,
sites: company.sites,
people: company.people,
onCreateSite: (onCreated: (id: string) => void) => setQuick({ kind: "site", onCreated }),

View File

@ -1,4 +1,4 @@
import { AssetCatalogPage } from "@metis/assets/client";
import { AssetCatalogPage, InfrastructurePage } from "@metis/assets/client";
import { useBrowserSearchParams, useCreateNavigation } from "@metis/core/client";
import { ResourceCatalogPage, ResourcesPage } from "@metis/resources/client";
@ -48,6 +48,8 @@ export function CompanySection({
const profileId = searchParams.get("profileId") ?? undefined;
switch (tab) {
case "infrastructure":
return <InfrastructurePage companyId={company.id} />;
case "overview":
return <CompanyOverview company={company} />;
case "catalogs":

View File

@ -12,6 +12,7 @@ export type CompanyTab =
| "resources"
| "assets"
| "inventory"
| "infrastructure"
| "workstations"
| "certificates"
| "applications"

View File

@ -0,0 +1,98 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { UniqueID } from "@repo/rdx-ddd";
import { PrinterConfigurationSchema } from "../../../../common/printer-configurations";
import { WorkProfileSchema } from "../../../../common/work-profiles";
import { printerUsage } from "./printer-usage";
const id = () => UniqueID.generateNewID().toPrimitive();
const companyId = id();
const actorId = id();
const printerId = id();
const a1 = id();
const a2 = id();
function profile(assetId: string) {
const values = {
name: "Perfil",
assetId,
personId: id(),
accountId: null,
active: true,
notes: null,
};
return WorkProfileSchema.parse({
id: id(),
companyId,
values,
needsReview: false,
history: [
{ id: id(), values, actorId, actorName: "Técnico", recordedAt: "2026-10-17T10:00:00.000Z" },
],
});
}
function configuration(
profileId: string,
state: "configured" | "pending" | "removal_pending" | "removed" | "never_configured"
) {
const values = {
profileId,
printerId,
connection: "network",
isDefault: false,
state,
notes: null,
};
return PrinterConfigurationSchema.parse({
id: id(),
companyId,
values,
needsReview: false,
history: [
{ id: id(), values, actorId, actorName: "Técnico", recordedAt: "2026-10-17T10:00:00.000Z" },
],
});
}
test("uso de impresora: agrupa perfiles de un mismo equipo y separa pendientes e historial", () => {
const one = profile(a1);
const two = profile(a1);
const three = profile(a2);
const configurations = [
configuration(one.id, "configured"),
configuration(two.id, "pending"),
configuration(three.id, "removal_pending"),
configuration(one.id, "removed"),
configuration(two.id, "never_configured"),
];
const usage = printerUsage(
printerId,
configurations,
[one, two, three],
[
{ id: a1, values: { name: "A1" } },
{ id: a2, values: { name: "A2" } },
]
);
assert.equal(usage.groups.length, 2);
assert.equal(usage.groups[0]?.configurations.length, 2);
assert.equal(usage.groups[1]?.configurations[0]?.configuration.values.state, "removal_pending");
assert.equal(usage.history.length, 2);
assert.equal(printerUsage(id(), configurations, [one, two, three], []).groups.length, 0);
});
test("uso de impresora: referencias sin resolver no se presentan como ausencia de uso", () => {
const missing = configuration(id(), "configured");
const usage = printerUsage(printerId, [missing], [], []);
assert.equal(usage.groups.length, 0);
assert.equal(usage.unresolved.length, 1);
});

View File

@ -0,0 +1,57 @@
import type { PrinterConfigurationDTO } from "../../../../common/printer-configurations";
import type { WorkProfileDTO } from "../../../../common/work-profiles";
/** Agrupa el uso registrado por equipo; conserva estados pendientes y separa el historial terminal. */
export function printerUsage(
assetId: string,
configurations: readonly PrinterConfigurationDTO[],
profiles: readonly WorkProfileDTO[],
assets: readonly { id: string; values: { name: string } }[]
) {
const groups = new Map<
string,
{
id: string;
name: string;
configurations: { configuration: PrinterConfigurationDTO; profile: WorkProfileDTO }[];
}
>();
const history = configurations.filter(
(item) =>
item.values.printerId === assetId &&
["removed", "never_configured"].includes(item.values.state)
);
const unresolved = configurations.filter(
(item) =>
item.values.printerId === assetId &&
!["removed", "never_configured"].includes(item.values.state) &&
!profiles.some((profile) => profile.id === item.values.profileId)
);
for (const item of configurations.filter(
(entry) =>
entry.values.printerId === assetId &&
!["removed", "never_configured"].includes(entry.values.state)
)) {
const profile = profiles.find((entry) => entry.id === item.values.profileId);
if (!profile) {
continue;
}
const id = profile.values.assetId;
const group = groups.get(id) ?? {
id,
name: assets.find((entry) => entry.id === id)?.values.name ?? "—",
configurations: [],
};
group.configurations.push({ configuration: item, profile });
groups.set(id, group);
}
return {
groups: [...groups.values()].sort((a, b) => a.name.localeCompare(b.name)),
history,
unresolved,
};
}

View File

@ -0,0 +1,118 @@
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
import { BrowserLink, useListQuery } from "@metis/core/client";
import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next";
import type { CompanyDTO } from "../../../../common";
import { printerApi, printerKeys } from "../../../printer-configurations/shared/api";
import { profileApi, profileKeys } from "../../../work-profiles/shared/api";
import { printerUsage } from "../helpers/printer-usage";
export function AssetPrinterUsage({ company, assetId }: { company: CompanyDTO; assetId: string }) {
const { t } = useTranslation("companies");
const printers = useListQuery({
queryKey: printerKeys.company(company.id),
queryFn: () => printerApi.get(company.id),
});
const profiles = useListQuery({
queryKey: profileKeys.company(company.id),
queryFn: () => profileApi.get(company.id),
});
const inventory = useListQuery({
queryKey: inventoryKeys.company(company.id),
queryFn: () => inventoryApi.get(company.id),
});
const failed = printers.isError || profiles.isError || inventory.isError;
const usage = printerUsage(
assetId,
printers.data?.configurations ?? [],
profiles.data?.profiles ?? [],
inventory.data?.assets ?? []
);
if (printers.isPending || profiles.isPending || inventory.isPending) {
return <p role="status">{t("loading")}</p>;
}
if (failed) {
return (
<div>
<p role="alert">{t("infrastructureUsage.error")}</p>
<Button
onClick={() => {
Promise.all([printers.refetch(), profiles.refetch(), inventory.refetch()]).catch(
() => undefined
);
}}
size="sm"
variant="outline"
>
{t("retry")}
</Button>
</div>
);
}
return (
<div className="space-y-3">
{!usage.groups.length && (
<p className="text-sm text-muted-foreground">{t("infrastructureUsage.empty")}</p>
)}
{usage.unresolved.length > 0 && <p role="alert">{t("infrastructureUsage.incomplete")}</p>}
<ul className="space-y-3">
{usage.groups.map((group) => (
<li className="rounded-md border p-3 text-sm" key={group.id}>
<BrowserLink
className="font-medium"
href={`/companies/${company.id}/assets/${group.id}`}
>
{group.name}
</BrowserLink>
<ul className="mt-2 space-y-2">
{group.configurations.map(({ profile, configuration }) => (
<li key={configuration.id}>
<BrowserLink href={`/companies/${company.id}/work-profiles/${profile.id}`}>
{profile.values.name}
</BrowserLink>{" "}
·{" "}
{company.people.find((person) => person.id === profile.values.personId)?.name ??
"—"}
<p className="text-muted-foreground">
{t(`mail.${configuration.values.state}`)} ·{" "}
{t(`printers.${configuration.values.connection}`)}
{configuration.values.isDefault ? ` · ${t("infrastructureUsage.default")}` : ""}
</p>
{(!profile.values.active || configuration.needsReview) && (
<p className="text-amber-700 dark:text-amber-400">
{t("infrastructureUsage.review")}
</p>
)}
</li>
))}
</ul>
</li>
))}
</ul>
{usage.history.length > 0 && (
<details>
<summary className="cursor-pointer text-sm">
{t("infrastructureUsage.history", { count: usage.history.length })}
</summary>
<ul className="mt-2 space-y-2">
{usage.history.map((item) => (
<li className="text-sm" key={item.id}>
<BrowserLink
href={`/companies/${company.id}/work-profiles/${item.values.profileId}`}
>
{profiles.data?.profiles.find((profile) => profile.id === item.values.profileId)
?.values.name ?? "—"}
</BrowserLink>{" "}
· {t(`mail.${item.values.state}`)}
</li>
))}
</ul>
</details>
)}
<p className="text-xs text-muted-foreground">{t("infrastructureUsage.notice")}</p>
</div>
);
}

View File

@ -8,6 +8,7 @@ import type { GlobalCatalog, WorkspaceLocation } from "../types/workspace-locati
export const COMPANY_SECTION_PATHS = {
overview: "",
inventory: "assets",
infrastructure: "infrastructure",
resources: "resources",
workstations: "workstations",
profiles: "work-profiles",

View File

@ -89,6 +89,7 @@ test("todas las secciones de compañía tienen una ruta propia y el índice es e
for (const section of [
"overview",
"inventory",
"infrastructure",
"resources",
"workstations",
"profiles",