From ea1e5e450fd2a254058a87f77c22a6a7d492d6a2 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 7 Oct 2026 10:16:39 +0200 Subject: [PATCH] =?UTF-8?q?Asistencia=20t=C3=A9cnica=20remota?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/src/identity.test.ts | 12 ++ apps/server/src/support.integration.ts | 145 +++++++++++++ .../modelo-ddd-e-identificadores.md | 4 + docs/asistencia-tecnica-remota.md | 34 +++ docs/entorno-actual.md | 2 + docs/inventario-de-activos.md | 2 + docs/plan-de-desarrollo.md | 8 +- modules/assets/package.json | 2 +- .../api/application/support-input.mapper.ts | 49 +++++ .../application/support-response.builder.ts | 29 +++ .../src/api/application/support.repository.ts | 26 +++ .../src/api/application/support.use-cases.ts | 83 ++++++++ .../support-connection-revision.entity.ts | 25 +++ .../domain/support-connection.aggregate.ts | 81 +++++++ .../src/api/domain/support-tool.aggregate.ts | 45 ++++ .../assets/src/api/domain/support-values.ts | 22 ++ modules/assets/src/api/domain/support.test.ts | 128 +++++++++++ .../domain/value-objects/support-values.ts | 37 ++++ .../api/infrastructure/http/support.routes.ts | 110 ++++++++++ .../sequelize-support.repository.ts | 130 ++++++++++++ .../persistence/support-migration.ts | 73 +++++++ .../persistence/support-persistence.mapper.ts | 140 +++++++++++++ modules/assets/src/api/module.ts | 7 + modules/assets/src/common/index.ts | 1 + modules/assets/src/common/support.ts | 38 ++++ modules/assets/src/web/asset-support.tsx | 198 ++++++++++++++++++ modules/assets/src/web/i18n.ts | 3 + modules/assets/src/web/index.ts | 1 + modules/assets/src/web/inventory-page.tsx | 13 ++ modules/assets/src/web/support-api.ts | 54 +++++ modules/assets/src/web/support-form.tsx | 154 ++++++++++++++ modules/assets/src/web/support-help.tsx | 30 +++ modules/assets/src/web/support-i18n.ts | 108 ++++++++++ modules/assets/src/web/support-tool-form.tsx | 75 +++++++ modules/assets/src/web/support-tools.tsx | 77 +++++++ modules/assets/src/web/support.test.tsx | 81 +++++++ .../web/profiles/work-environment-page.tsx | 17 +- .../web/profiles/work-environment.test.tsx | 1 + .../src/web/profiles/work-profiles-page.tsx | 1 + 39 files changed, 2042 insertions(+), 4 deletions(-) create mode 100644 apps/server/src/support.integration.ts create mode 100644 docs/asistencia-tecnica-remota.md create mode 100644 modules/assets/src/api/application/support-input.mapper.ts create mode 100644 modules/assets/src/api/application/support-response.builder.ts create mode 100644 modules/assets/src/api/application/support.repository.ts create mode 100644 modules/assets/src/api/application/support.use-cases.ts create mode 100644 modules/assets/src/api/domain/support-connection-revision.entity.ts create mode 100644 modules/assets/src/api/domain/support-connection.aggregate.ts create mode 100644 modules/assets/src/api/domain/support-tool.aggregate.ts create mode 100644 modules/assets/src/api/domain/support-values.ts create mode 100644 modules/assets/src/api/domain/support.test.ts create mode 100644 modules/assets/src/api/domain/value-objects/support-values.ts create mode 100644 modules/assets/src/api/infrastructure/http/support.routes.ts create mode 100644 modules/assets/src/api/infrastructure/persistence/sequelize-support.repository.ts create mode 100644 modules/assets/src/api/infrastructure/persistence/support-migration.ts create mode 100644 modules/assets/src/api/infrastructure/persistence/support-persistence.mapper.ts create mode 100644 modules/assets/src/common/support.ts create mode 100644 modules/assets/src/web/asset-support.tsx create mode 100644 modules/assets/src/web/support-api.ts create mode 100644 modules/assets/src/web/support-form.tsx create mode 100644 modules/assets/src/web/support-help.tsx create mode 100644 modules/assets/src/web/support-i18n.ts create mode 100644 modules/assets/src/web/support-tool-form.tsx create mode 100644 modules/assets/src/web/support-tools.tsx create mode 100644 modules/assets/src/web/support.test.tsx diff --git a/apps/server/src/identity.test.ts b/apps/server/src/identity.test.ts index ddfa133..90d0341 100644 --- a/apps/server/src/identity.test.ts +++ b/apps/server/src/identity.test.ts @@ -34,6 +34,7 @@ import { verifyRelationships } from "./relationships.integration"; import { verifyRemoteConnections } from "./remote.integration"; import { verifyResourceMigration } from "./resource-migration.integration"; import { verifyResources } from "./resources.integration"; +import { verifySupport } from "./support.integration"; import { verifyTelework } from "./telework.integration"; import { verifyWorkProfiles } from "./work-profiles.integration"; import { verifyWorkstations } from "./workstations.integration"; @@ -75,6 +76,10 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de await database.query("DELETE FROM remote_connections"); await database.query("DELETE FROM telework_authorization_revisions"); await database.query("DELETE FROM telework_authorizations"); + await database.query("DELETE FROM asset_support_revisions"); + await database.query("DELETE FROM asset_support"); + await database.query("DELETE FROM support_tools WHERE builtin=0"); + await database.query("UPDATE support_tools SET active=1 WHERE builtin=1"); await database.query("DELETE FROM work_profile_revisions"); await database.query("DELETE FROM work_profiles"); await database.query("DELETE FROM workstation_assets"); @@ -812,6 +817,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de secondCompany.id, database ); + await verifySupport((...args) => operator.request(...args), customer.id, secondCompany.id); // El entorno normal bloquea las fichas antiguas; el modo de fixtures anterior no se usa aquí. const migratedRuntime = await createIdentityRuntime(database, environment); const migratedServer = createApp(() => undefined, migratedRuntime).listen(0, "127.0.0.1"); @@ -897,6 +903,12 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de ); await expectError(await operator.request(`/processes/${customer.id}`), 401, "UNAUTHENTICATED"); await expectError(await operator.request("/processes/templates"), 401, "UNAUTHENTICATED"); + await expectError(await operator.request("/asset-support/tools"), 401, "UNAUTHENTICATED"); + await expectError( + await operator.request(`/asset-support/${customer.id}/${customer.id}`), + 401, + "UNAUTHENTICATED" + ); await expectError( await operator.request(`/processes/${customer.id}`, {}), 401, diff --git a/apps/server/src/support.integration.ts b/apps/server/src/support.integration.ts new file mode 100644 index 0000000..c023c9c --- /dev/null +++ b/apps/server/src/support.integration.ts @@ -0,0 +1,145 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; + +import { + InventoryResponseSchema, + SupportListSchema, + SupportResponseSchema, + SupportToolResponseSchema, + SupportToolsSchema, +} from "@metis/assets/common"; +import { z } from "zod/v4"; + +type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise; +export async function verifySupport(request: Request, companyId: string, otherId: string) { + const host = InventoryResponseSchema.parse( + await (await request(`/assets/${companyId}`)).json() + ).assets.find((item) => item.values.status !== "retired"); + assert.ok(host); + const tools = SupportToolsSchema.parse( + await (await request("/asset-support/tools")).json() + ).tools; + const tool = tools.find((item) => item.name === "AnyDesk"); + assert.ok(tool); + for (const name of ["AnyDesk", "Supremo Control", "Escritorio remoto", "TeamViewer"]) { + assert.ok(tools.some((item) => item.name === name)); + } + const path = `/asset-support/${companyId}/${host.id}`; + const values = { + toolId: tool.id, + destination: "soporte-test", + requiresVpn: false, + preferred: true, + state: "available" as const, + notes: "Test", + }; + async function error(url: string, body: unknown, status: number, code: string) { + const response = await request(url, body); + assert.equal(response.status, status); + assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code); + } + const saved = await request(path, { values }); + assert.equal(saved.status, 201, await saved.clone().text()); + const first = SupportResponseSchema.parse(await saved.json()).connection; + assert.equal(first.id[14], "7"); + assert.equal(first.history.length, 1); + await error(`/asset-support/${otherId}/${host.id}`, { values }, 404, "ASSET_NOT_FOUND"); + await error( + path, + { values: { ...values, preferred: true, state: "pending" } }, + 400, + "INVALID_REQUEST" + ); + await error(path, { values }, 409, "PREFERRED_EXISTS"); + const secondValues = { + ...values, + toolId: tools.find((item) => item.name === "Supremo Control")?.id, + preferred: false, + }; + const second = await request(path, { values: secondValues }); + assert.equal(second.status, 201); + const secondItem = SupportResponseSchema.parse(await second.json()).connection; + await error(path, { values: secondValues }, 409, "CONNECTION_EXISTS"); + await error(`${path}/${randomUUID()}/update`, { values }, 404, "CONNECTION_NOT_FOUND"); + const same = await request(`${path}/${first.id}/update`, { values }); + assert.equal(same.status, 200); + assert.equal(SupportResponseSchema.parse(await same.json()).connection.history.length, 1); + const update = await request(`${path}/${first.id}/update`, { + values: { ...values, requiresVpn: true }, + }); + assert.equal(update.status, 200); + const changed = SupportResponseSchema.parse(await update.json()).connection; + assert.equal(changed.history.length, 2); + assert.equal(changed.history[0]?.values.requiresVpn, false); + const switched = await request(`${path}/${first.id}/update`, { + values: { ...changed.values, preferred: false }, + }); + assert.equal(switched.status, 200); + assert.equal( + ( + await request(`${path}/${secondItem.id}/update`, { + values: { ...secondItem.values, preferred: true }, + }) + ).status, + 200 + ); + assert.equal((await request(`${path}?unknown=1`)).status, 400); + assert.equal((await request(path, { values }, "http://evil.example")).status, 403); + assert.equal((await request(path, { values }, undefined, "invalid-token")).status, 403); + const custom = await request("/asset-support/tools", { + values: { name: "RustDesk test", active: true }, + }); + assert.equal(custom.status, 201); + const customTool = SupportToolResponseSchema.parse(await custom.json()).tool; + await error( + "/asset-support/tools", + { values: { name: "rustdesk TEST", active: true } }, + 409, + "TOOL_EXISTS" + ); + await error( + `/asset-support/tools/${tool.id}/update`, + { values: { name: "Renamed", active: true } }, + 409, + "BUILTIN_PROTECTED" + ); + assert.equal( + ( + await request(`/asset-support/tools/${customTool.id}/update`, { + values: { name: customTool.name, active: false }, + }) + ).status, + 200 + ); + await error( + path, + { values: { ...values, toolId: customTool.id, preferred: false } }, + 409, + "TOOL_UNAVAILABLE" + ); + const listed = SupportListSchema.parse(await (await request(path)).json()); + assert.equal(listed.connections.length, 2); + assert.equal(listed.connections.filter((item) => item.values.preferred).length, 1); + const retired = await request(`/assets/${companyId}/${host.id}/update`, { + values: { ...host.values, status: "retired" }, + }); + assert.equal(retired.status, 200); + await error( + `${path}/${first.id}/update`, + { values: { ...values, preferred: false } }, + 409, + "ASSET_RETIRED" + ); + assert.equal( + ( + await request(`${path}/${first.id}/update`, { + values: { ...values, preferred: false, state: "retired" }, + }) + ).status, + 200 + ); + assert.equal( + (await request(`/assets/${companyId}/${host.id}/update`, { values: host.values })).status, + 200 + ); +} diff --git a/docs/arquitectura/modelo-ddd-e-identificadores.md b/docs/arquitectura/modelo-ddd-e-identificadores.md index 887711f..15cc338 100644 --- a/docs/arquitectura/modelo-ddd-e-identificadores.md +++ b/docs/arquitectura/modelo-ddd-e-identificadores.md @@ -86,3 +86,7 @@ RemoteConnection es AggregateRoot; RemoteConnectionRevision es DomainEntity con ## Procesos de personas y plantillas ProcessTemplate y PersonProcess son AggregateRoot; sus revisiones y las tareas ProcessTask son DomainEntity con UniqueID v7. ProcessTemplateValues y ProcessValues encapsulan nombre Name, referencias UniqueID y opcionales Maybe; las referencias de tarea tienen también Value Object. Instrucciones de plantilla son valores sin identidad propia; cada tarea de ejecución tiene su UUID, preservado al resolverla. La ejecución conserva la plantilla inicial y su historial. Consultar [Procesos de personas](../procesos-de-personas.md). + +## Asistencia técnica remota + +SupportConnection delimita cada método de asistencia de un activo de una compañía. SupportValues encapsula la referencia UniqueID a herramienta y los valores de configuración; SupportConnectionRevision conserva las instantáneas con actor y fecha. SupportTool es la raíz del catálogo global y utiliza Name. Todas las raíces y revisiones nuevas tienen UUID v7. Consultar [Asistencia técnica remota](../asistencia-tecnica-remota.md). diff --git a/docs/asistencia-tecnica-remota.md b/docs/asistencia-tecnica-remota.md new file mode 100644 index 0000000..10b969f --- /dev/null +++ b/docs/asistencia-tecnica-remota.md @@ -0,0 +1,34 @@ +# Asistencia técnica remota + +Cada activo puede tener varios métodos para que un técnico le preste soporte. Se consultan y gestionan desde la ficha del activo, incluida la ficha lateral, y desde Entorno actual del perfil de usuario, para su dispositivo principal. + +## Datos y reglas + +- Herramienta del catálogo global: AnyDesk, Supremo Control, Escritorio remoto y TeamViewer vienen de serie. +- Identificador o destino opcional, requisito VPN, estado pendiente de configurar/disponible/retirado y notas opcionales. +- Un único método preferido por activo, opcional y solo cuando está disponible. Para cambiarlo se desmarca el anterior antes de marcar el nuevo. +- Se permiten varios registros de una herramienta cuando tienen destinos diferentes. Se rechaza repetir una herramienta y destino vigentes, sin distinguir mayúsculas. Los registros retirados conservan historial y no bloquean una nueva alta. +- Un activo retirado conserva sus métodos para revisión; solo admite guardar métodos retirados. Retirar el activo no altera ni desconecta los métodos reales. +- Cada modificación semántica añade una revisión con UUID, actor y fecha. Guardar sin cambios no duplica el historial. No hay eliminación. +- Las herramientas pueden añadirse, editarse y activarse/desactivarse. Los nombres de serie están protegidos. Desactivar impide seleccionarlas en altas y cambios hacia esa herramienta, mantiene las referencias existentes y muestra un aviso de revisión. + +Los formularios son modales. El desplegable de herramienta usa SelectWithAction para el alta rápida, conservando el formulario padre y seleccionando el elemento creado. La ayuda precede a Guardar/Cancelar. La gestión del catálogo también se abre en modal. + +Esta información documenta la asistencia del técnico al activo. No concede autorizaciones de teletrabajo, no establece conexiones y no almacena contraseñas ni claves; las credenciales requieren una solución específica. + +## Persistencia y contratos + +SupportConnection es AggregateRoot con SupportValues y SupportConnectionRevision como DomainEntity; SupportTool es otro AggregateRoot con Name. UUID v7 en nuevas raíces y revisiones; rehydrate preserva identidades e historial. + +Migración explícita e idempotente: support_tools, asset_support, asset_support_revisions y el bloqueo técnico support_catalog_lock. FK compuesta valida la pertenencia del activo a la compañía. FK a herramientas y actores; clave única para el preferido. Las escrituras bloquean la compañía y las altas de herramientas serializan el catálogo. + +API autenticada /api/asset-support: +- GET /tools; POST /tools; POST /tools/:id/update. +- GET /:companyId/:assetId; POST /:companyId/:assetId. +- POST /:companyId/:assetId/:id/update. + +Entradas y salidas estrictas, CSRF y origen aplicados por el servidor. UUID de la compañía y activo validados; los registros ajenos a ese activo no se pueden editar. + +## Verificación + +Pruebas de dominio, mapper, interfaz e integración HTTP/MariaDB: varios métodos, preferido único, duplicados, historial/idempotencia, protección de herramientas de serie, inactividad, retirada de activo, pertenencia, autenticación, CSRF y origen. No se crean datos de prueba en la base de desarrollo. diff --git a/docs/entorno-actual.md b/docs/entorno-actual.md index cf9f807..19ca289 100644 --- a/docs/entorno-actual.md +++ b/docs/entorno-actual.md @@ -37,3 +37,5 @@ En navegador se comprueban los datos reales del perfil, el filtro de atención, La ficha consulta también la [autorización de teletrabajo](teletrabajo.md), común a los perfiles de la persona y separada de las configuraciones del dispositivo. Muestra vigencia y revisión o ausencia de registro; permite gestionar la autorización y volver al mismo perfil. La ficha muestra también [accesos remotos](accesos-remotos.md) abiertos de la persona, incluidos los que parten de otros dispositivos o equipos personales. Los recorridos y avisos aparecen en un bloque independiente de las configuraciones del perfil. Se excluyen cerrados y registros de otras personas/compañías. Gestionar abre la consulta filtrada y Volver conserva el perfil seleccionado. + +La sección Asistencia técnica remota permite consultar y gestionar los métodos de soporte del dispositivo principal mediante modales. Estos métodos pertenecen al activo y se muestran en todos sus perfiles de usuario; no son permisos de teletrabajo de la persona. Consultar [Asistencia técnica remota](asistencia-tecnica-remota.md). diff --git a/docs/inventario-de-activos.md b/docs/inventario-de-activos.md index 487a260..2158754 100644 --- a/docs/inventario-de-activos.md +++ b/docs/inventario-de-activos.md @@ -43,3 +43,5 @@ GET /api/assets/:companyId consulta el inventario. POST /api/assets/:companyId y Las pruebas verifican características opcionales y obligatorias, tipos, fechas, cero y falso, UUID, igualdad semántica, aislamiento por compañía, historial, desactivaciones, rehidratación y protección de las escrituras. La comprobación en navegador incluye alta rápida, cancelación, conservación de fechas y consulta del historial. Las [relaciones entre activos](relaciones-entre-activos.md) están disponibles en ambas fichas. Los [puestos de trabajo](puestos-de-trabajo.md) están disponibles como agrupaciones sin anidación. Los perfiles y sus configuraciones están implementados, así como la [autorización de teletrabajo](teletrabajo.md). La conectividad remota, el registro mínimo de equipos personales y los procesos de salida mantienen su orden en el plan. + +La ficha de cada activo, incluida su vista lateral, incorpora [Asistencia técnica remota](asistencia-tecnica-remota.md): varios métodos, catálogo de herramientas, preferido opcional e historial. diff --git a/docs/plan-de-desarrollo.md b/docs/plan-de-desarrollo.md index 9e55c08..4de384b 100644 --- a/docs/plan-de-desarrollo.md +++ b/docs/plan-de-desarrollo.md @@ -79,10 +79,14 @@ Origen corporativo o personal sin inventario detallado, herramienta libre, desti Plantillas globales de alta/cambio/salida, copias de instrucciones por ejecución, seguimiento por persona con tareas UUID, resultados, cierres, historial y revisión de registros actuales. Cambio y salida incorporan revisiones de cuentas, configuraciones, activos asignados y accesos remotos personales. Modales, altas rápidas y protección frente a ediciones concurrentes. Consultar [Procesos de personas](procesos-de-personas.md). +## Asistencia técnica remota: implementada + +Varios métodos por activo, catálogo global ampliable con herramientas de serie, destino opcional, requisito VPN, estados, preferido opcional e historial. Modales en ficha del activo y Entorno actual. Consultar [Asistencia técnica remota](asistencia-tecnica-remota.md). + ## Siguientes entregas: orden actualizado -1. Asistencia técnica remota independiente de los accesos de las personas. Vinculación explícita entre un acceso y su VPN previa si resulta necesaria. -2. Tickets e intervenciones con técnico, tiempos y elementos afectados. +1. Tickets e intervenciones: concretar las peticiones recibidas por email/teléfono, solicitante y elementos afectados; múltiples intervenciones por ticket e intervenciones internas sin ticket. +2. Vinculación explícita entre un acceso remoto y su VPN previa, si resulta necesaria. Este orden se concreta antes de cada entrega. Las licencias y su relación con cuentas se explorarán posteriormente. diff --git a/modules/assets/package.json b/modules/assets/package.json index 9cdf84f..5540ff9 100644 --- a/modules/assets/package.json +++ b/modules/assets/package.json @@ -10,7 +10,7 @@ }, "scripts": { "typecheck": "tsc -p tsconfig.json", - "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/api/domain/workstation.test.ts src/api/domain/asset-relationship.test.ts src/web/catalog.test.tsx src/web/inventory.test.tsx" + "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/api/domain/workstation.test.ts src/api/domain/asset-relationship.test.ts src/web/catalog.test.tsx src/web/inventory.test.tsx src/api/domain/support.test.ts src/web/support.test.tsx" }, "dependencies": { "@repo/rdx-ddd": "workspace:*", diff --git a/modules/assets/src/api/application/support-input.mapper.ts b/modules/assets/src/api/application/support-input.mapper.ts new file mode 100644 index 0000000..a71cf2f --- /dev/null +++ b/modules/assets/src/api/application/support-input.mapper.ts @@ -0,0 +1,49 @@ +import { Result } from "@repo/rdx-utils"; + +import type { SupportConnection } from "../domain/support-connection.aggregate"; +import { SupportError, type SupportInput } from "../domain/support-values"; +import { SupportValues } from "../domain/value-objects/support-values"; + +import type { SupportContext } from "./support.repository"; +export function mapSupportInput( + input: SupportInput, + context: SupportContext, + previous?: SupportConnection +) { + const values = SupportValues.create(input); + if (!values.isSuccess) { + return values; + } + const tool = context.tools.find((item) => item.id.equals(values.data.toolId)); + if (!(tool && (tool.active || previous?.values.toolId.equals(tool.id)))) { + return Result.fail(new SupportError("TOOL_UNAVAILABLE")); + } + if (context.assetRetired && input.state !== "retired") { + return Result.fail(new SupportError("ASSET_RETIRED")); + } + if ( + input.preferred && + context.connections.some( + (item) => + item.values.toPrimitive().preferred && item.id.toPrimitive() !== previous?.id.toPrimitive() + ) + ) { + return Result.fail(new SupportError("PREFERRED_EXISTS")); + } + const next = values.data.toPrimitive(); + if ( + next.state !== "retired" && + context.connections.some((item) => { + const other = item.values.toPrimitive(); + return ( + item.id.toPrimitive() !== previous?.id.toPrimitive() && + other.state !== "retired" && + other.toolId === next.toolId && + other.destination.toLowerCase() === next.destination.toLowerCase() + ); + }) + ) { + return Result.fail(new SupportError("CONNECTION_EXISTS")); + } + return values; +} diff --git a/modules/assets/src/api/application/support-response.builder.ts b/modules/assets/src/api/application/support-response.builder.ts new file mode 100644 index 0000000..01063ae --- /dev/null +++ b/modules/assets/src/api/application/support-response.builder.ts @@ -0,0 +1,29 @@ +import type { SupportConnectionDTO, SupportToolDTO } from "../../common/support"; +import type { SupportConnection } from "../domain/support-connection.aggregate"; +import type { SupportTool } from "../domain/support-tool.aggregate"; +export function buildSupportTool(tool: SupportTool): SupportToolDTO { + return { + id: tool.id.toPrimitive(), + name: tool.name.toPrimitive(), + active: tool.active, + builtin: tool.builtin, + }; +} +export function buildSupport( + connection: SupportConnection, + names: ReadonlyMap +): SupportConnectionDTO { + return { + id: connection.id.toPrimitive(), + companyId: connection.companyId.toPrimitive(), + assetId: connection.assetId.toPrimitive(), + values: connection.values.toPrimitive(), + history: connection.history.map((revision) => ({ + id: revision.id.toPrimitive(), + values: revision.values.toPrimitive(), + actorId: revision.actorId.toPrimitive(), + actorName: names.get(revision.actorId.toPrimitive()) ?? revision.actorId.toPrimitive(), + recordedAt: revision.recordedAt, + })), + }; +} diff --git a/modules/assets/src/api/application/support.repository.ts b/modules/assets/src/api/application/support.repository.ts new file mode 100644 index 0000000..7743332 --- /dev/null +++ b/modules/assets/src/api/application/support.repository.ts @@ -0,0 +1,26 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import type { Result } from "@repo/rdx-utils"; + +import type { SupportConnection } from "../domain/support-connection.aggregate"; +import type { SupportTool } from "../domain/support-tool.aggregate"; +import type { SupportError } from "../domain/support-values"; +export type SupportContext = Readonly<{ + connections: readonly SupportConnection[]; + tools: readonly SupportTool[]; + assetRetired: boolean; + actorNames: ReadonlyMap; +}>; +export interface ISupportRepository { + get(company: UniqueID, asset: UniqueID): Promise>; + save( + company: UniqueID, + asset: UniqueID, + action: (context: SupportContext) => Result + ): Promise< + Result<{ connection: SupportConnection; actorNames: ReadonlyMap }, SupportError> + >; + tools(): Promise; + saveTool( + action: (tools: readonly SupportTool[]) => Result + ): Promise>; +} diff --git a/modules/assets/src/api/application/support.use-cases.ts b/modules/assets/src/api/application/support.use-cases.ts new file mode 100644 index 0000000..e8e2611 --- /dev/null +++ b/modules/assets/src/api/application/support.use-cases.ts @@ -0,0 +1,83 @@ +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { SupportConnection } from "../domain/support-connection.aggregate"; +import { SupportTool } from "../domain/support-tool.aggregate"; +import { SupportError, type SupportInput, type SupportToolInput } from "../domain/support-values"; + +import type { ISupportRepository } from "./support.repository"; +import { mapSupportInput } from "./support-input.mapper"; +export class SupportUseCases { + constructor(private readonly repository: ISupportRepository) {} + get(companyId: string, assetId: string) { + const company = UniqueID.create(companyId); + const asset = UniqueID.create(assetId); + if (!(company.isSuccess && asset.isSuccess)) { + return Promise.resolve(Result.fail(new SupportError("INVALID_REQUEST"))); + } + return this.repository.get(company.data, asset.data); + } + tools() { + return this.repository.tools(); + } + save( + companyId: string, + assetId: string, + id: string | undefined, + input: SupportInput, + actorId: string + ) { + const company = UniqueID.create(companyId); + const asset = UniqueID.create(assetId); + const actor = UniqueID.create(actorId); + const identity = id ? UniqueID.create(id) : undefined; + if ( + !(company.isSuccess && asset.isSuccess && actor.isSuccess) || + (identity && !identity.isSuccess) + ) { + return Promise.resolve(Result.fail(new SupportError("INVALID_REQUEST"))); + } + return this.repository.save(company.data, asset.data, (context) => { + const previous = identity?.isSuccess + ? context.connections.find((item) => item.id.equals(identity.data)) + : undefined; + if (id && !previous) { + return Result.fail(new SupportError("CONNECTION_NOT_FOUND")); + } + const values = mapSupportInput(input, context, previous); + if (!values.isSuccess) { + return Result.fail(values.error); + } + const now = new Date().toISOString(); + return previous + ? previous.update(values.data, actor.data, now) + : SupportConnection.create(company.data, asset.data, values.data, actor.data, now); + }); + } + saveTool(id: string | undefined, input: SupportToolInput) { + const identity = id ? UniqueID.create(id) : undefined; + if (identity && !identity.isSuccess) { + return Promise.resolve(Result.fail(new SupportError("INVALID_REQUEST"))); + } + return this.repository.saveTool((tools) => { + const previous = identity?.isSuccess + ? tools.find((item) => item.id.equals(identity.data)) + : undefined; + if (id && !previous) { + return Result.fail(new SupportError("TOOL_NOT_FOUND")); + } + if ( + tools.some( + (item) => + item.id.toPrimitive() !== previous?.id.toPrimitive() && + item.name + .toPrimitive() + .localeCompare(input.name.trim(), "en", { sensitivity: "base" }) === 0 + ) + ) { + return Result.fail(new SupportError("TOOL_EXISTS")); + } + return previous ? previous.update(input) : SupportTool.create(input); + }); + } +} diff --git a/modules/assets/src/api/domain/support-connection-revision.entity.ts b/modules/assets/src/api/domain/support-connection-revision.entity.ts new file mode 100644 index 0000000..c5a3bcd --- /dev/null +++ b/modules/assets/src/api/domain/support-connection-revision.entity.ts @@ -0,0 +1,25 @@ +import { DomainEntity, type UniqueID } from "@repo/rdx-ddd"; + +import type { SupportValues } from "./value-objects/support-values"; +export type SupportConnectionRevisionProps = Readonly<{ + values: SupportValues; + actorId: UniqueID; + recordedAt: string; +}>; +export class SupportConnectionRevision extends DomainEntity { + static create(props: SupportConnectionRevisionProps) { + return new SupportConnectionRevision(Object.freeze({ ...props })); + } + static rehydrate(props: SupportConnectionRevisionProps, id: UniqueID) { + return new SupportConnectionRevision(Object.freeze({ ...props }), id); + } + get values() { + return this.props.values; + } + get actorId() { + return this.props.actorId; + } + get recordedAt() { + return this.props.recordedAt; + } +} diff --git a/modules/assets/src/api/domain/support-connection.aggregate.ts b/modules/assets/src/api/domain/support-connection.aggregate.ts new file mode 100644 index 0000000..990b996 --- /dev/null +++ b/modules/assets/src/api/domain/support-connection.aggregate.ts @@ -0,0 +1,81 @@ +import { AggregateRoot, type UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import { z } from "zod/v4"; + +import { SupportConnectionRevision } from "./support-connection-revision.entity"; +import { SupportError } from "./support-values"; +import type { SupportValues } from "./value-objects/support-values"; + +type SupportConnectionProps = Readonly<{ + companyId: UniqueID; + assetId: UniqueID; + values: SupportValues; + history: readonly SupportConnectionRevision[]; +}>; +export class SupportConnection extends AggregateRoot { + private constructor(props: SupportConnectionProps, id?: UniqueID) { + super(Object.freeze({ ...props, history: Object.freeze([...props.history]) }), id); + } + static create( + companyId: UniqueID, + assetId: UniqueID, + values: SupportValues, + actorId: UniqueID, + recordedAt: string + ): Result { + if (!z.iso.datetime().safeParse(recordedAt).success) { + return Result.fail(new SupportError("INVALID_REQUEST")); + } + return Result.ok( + new SupportConnection({ + companyId, + assetId, + values, + history: [SupportConnectionRevision.create({ values, actorId, recordedAt })], + }) + ); + } + static rehydrate(props: SupportConnectionProps, id: UniqueID) { + return new SupportConnection(props, id); + } + get assetId() { + return this.props.assetId; + } + get companyId() { + return this.props.companyId; + } + get values() { + return this.props.values; + } + get history() { + return this.props.history; + } + update( + values: SupportValues, + actorId: UniqueID, + recordedAt: string + ): Result { + if (this.values.equals(values)) { + return Result.ok(this); + } + if ( + !z.iso.datetime().safeParse(recordedAt).success || + recordedAt < (this.history.at(-1)?.recordedAt ?? "") + ) { + return Result.fail(new SupportError("INVALID_REQUEST")); + } + return Result.ok( + new SupportConnection( + { + ...this.props, + values, + history: [ + ...this.history, + SupportConnectionRevision.create({ values, actorId, recordedAt }), + ], + }, + this.id + ) + ); + } +} diff --git a/modules/assets/src/api/domain/support-tool.aggregate.ts b/modules/assets/src/api/domain/support-tool.aggregate.ts new file mode 100644 index 0000000..fa79165 --- /dev/null +++ b/modules/assets/src/api/domain/support-tool.aggregate.ts @@ -0,0 +1,45 @@ +import { AggregateRoot, Name, type UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { SupportError, type SupportToolInput, SupportToolInputSchema } from "./support-values"; + +type Props = Readonly<{ name: Name; active: boolean; builtin: boolean }>; +export class SupportTool extends AggregateRoot { + private constructor(props: Props, id?: UniqueID) { + super(Object.freeze({ ...props }), id); + } + static create(input: SupportToolInput, builtin = false): Result { + const name = Name.create(input.name, 100); + if (!(SupportToolInputSchema.safeParse(input).success && name.isSuccess)) { + return Result.fail(new SupportError("INVALID_REQUEST")); + } + return Result.ok(new SupportTool({ name: name.data, active: input.active, builtin })); + } + static rehydrate(props: Props, id: UniqueID) { + return new SupportTool(props, id); + } + get name() { + return this.props.name; + } + get active() { + return this.props.active; + } + get builtin() { + return this.props.builtin; + } + update(input: SupportToolInput): Result { + const name = Name.create(input.name, 100); + if (!(SupportToolInputSchema.safeParse(input).success && name.isSuccess)) { + return Result.fail(new SupportError("INVALID_REQUEST")); + } + if (this.builtin && !this.name.equals(name.data)) { + return Result.fail(new SupportError("BUILTIN_PROTECTED")); + } + if (this.name.equals(name.data) && this.active === input.active) { + return Result.ok(this); + } + return Result.ok( + new SupportTool({ ...this.props, name: name.data, active: input.active }, this.id) + ); + } +} diff --git a/modules/assets/src/api/domain/support-values.ts b/modules/assets/src/api/domain/support-values.ts new file mode 100644 index 0000000..f0d7e14 --- /dev/null +++ b/modules/assets/src/api/domain/support-values.ts @@ -0,0 +1,22 @@ +import { z } from "zod/v4"; +export const SupportValuesSchema = z + .strictObject({ + toolId: z.uuid(), + destination: z.string().trim().max(300), + requiresVpn: z.boolean(), + preferred: z.boolean(), + state: z.enum(["pending", "available", "retired"]), + notes: z.string().trim().max(2000), + }) + .refine((value) => !value.preferred || value.state === "available"); +export type SupportInput = z.infer; +export const SupportToolInputSchema = z.strictObject({ + name: z.string().trim().min(1).max(100), + active: z.boolean(), +}); +export type SupportToolInput = z.infer; +export class SupportError extends Error { + constructor(public readonly code: string) { + super(code); + } +} diff --git a/modules/assets/src/api/domain/support.test.ts b/modules/assets/src/api/domain/support.test.ts new file mode 100644 index 0000000..bf4a2ea --- /dev/null +++ b/modules/assets/src/api/domain/support.test.ts @@ -0,0 +1,128 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { UniqueID } from "@repo/rdx-ddd"; + +import { mapSupportInput } from "../application/support-input.mapper"; +import { SupportPersistenceMapper } from "../infrastructure/persistence/support-persistence.mapper"; + +import { SupportConnection } from "./support-connection.aggregate"; +import { SupportTool } from "./support-tool.aggregate"; +import { SupportValuesSchema } from "./support-values"; +import { SupportValues } from "./value-objects/support-values"; + +const company = UniqueID.generateNewID(); +const asset = UniqueID.generateNewID(); +const actor = UniqueID.generateNewID(); +const toolResult = SupportTool.create({ name: "AnyDesk", active: true }, true); +assert.ok(toolResult.isSuccess); +const tool = toolResult.data; +const input = { + toolId: tool.id.toPrimitive(), + destination: "123 456", + requiresVpn: false, + preferred: false, + state: "available" as const, + notes: "", +}; +function connection() { + const values = SupportValues.create(input); + assert.ok(values.isSuccess); + const result = SupportConnection.create( + company, + asset, + values.data, + actor, + "2026-10-07T10:00:00.000Z" + ); + assert.ok(result.isSuccess); + return result.data; +} +test("asistencia: UUID v7, historial, cambios semánticos y fechas", () => { + const item = connection(); + assert.equal(item.id.toPrimitive()[14], "7"); + assert.equal(item.history[0]?.id.toPrimitive()[14], "7"); + const same = item.update(item.values, actor, "2026-10-07T11:00:00.000Z"); + assert.ok(same.isSuccess); + assert.equal(same.data, item); + const values = SupportValues.create({ ...input, requiresVpn: true }); + assert.ok(values.isSuccess); + assert.equal(item.update(values.data, actor, "2026-10-06T10:00:00.000Z").isSuccess, false); + const changed = item.update(values.data, actor, "2026-10-07T11:00:00.000Z"); + assert.ok(changed.isSuccess); + assert.ok(changed.data.id.equals(item.id)); + assert.equal(changed.data.history.length, 2); + assert.equal(item.values.toPrimitive().requiresVpn, false); +}); +test("asistencia: preferido disponible y entrada estricta", () => { + assert.equal( + SupportValuesSchema.safeParse({ ...input, preferred: true, state: "pending" }).success, + false + ); + assert.equal( + SupportValuesSchema.safeParse({ ...input, destination: "x".repeat(301) }).success, + false + ); + assert.equal(SupportValuesSchema.safeParse({ ...input, password: "secret" }).success, false); + assert.equal(SupportValuesSchema.safeParse({ ...input, destination: "" }).success, true); +}); +test("asistencia: varios métodos, duplicados, preferido único y activo retirado", () => { + const first = connection(); + const context = { + connections: [first], + tools: [tool], + assetRetired: false, + actorNames: new Map(), + }; + const duplicate = mapSupportInput(input, context); + assert.equal(duplicate.isSuccess, false); + const distinct = mapSupportInput({ ...input, destination: "otro" }, context); + assert.ok(distinct.isSuccess); + const preferred = SupportValues.create({ ...input, preferred: true }); + assert.ok(preferred.isSuccess); + const updated = first.update(preferred.data, actor, "2026-10-07T11:00:00.000Z"); + assert.ok(updated.isSuccess); + assert.equal( + mapSupportInput( + { ...input, destination: "otro", preferred: true }, + { ...context, connections: [updated.data] } + ).isSuccess, + false + ); + assert.equal(mapSupportInput(input, { ...context, assetRetired: true }, first).isSuccess, false); + assert.ok( + mapSupportInput({ ...input, state: "retired" }, { ...context, assetRetired: true }, first) + .isSuccess + ); +}); +test("herramientas: protección de serie y desactivación sin perder referencias", () => { + assert.equal(tool.update({ name: "Otra", active: true }).isSuccess, false); + const disabled = tool.update({ name: "AnyDesk", active: false }); + assert.ok(disabled.isSuccess); + const context = { + connections: [], + tools: [disabled.data], + assetRetired: false, + actorNames: new Map(), + }; + assert.equal(mapSupportInput(input, context).isSuccess, false); + assert.ok(mapSupportInput({ ...input, state: "retired" }, context, connection()).isSuccess); +}); +test("asistencia: mapper bidireccional y rechazo de historial inconsistente", () => { + const item = connection(); + const mapper = new SupportPersistenceMapper(); + const rows = mapper.toPersistence(item); + const read = mapper.toDomain(rows.root, rows.revisions); + assert.ok(read.id.equals(item.id)); + assert.ok(read.values.equals(item.values)); + assert.throws(() => mapper.toDomain(rows.root, [])); + assert.throws(() => + mapper.toDomain({ ...rows.root, preferredKey: asset.toPrimitive() }, rows.revisions) + ); + assert.throws(() => + mapper.toDomain( + rows.root, + rows.revisions.map((row) => ({ ...row, position: 1 })) + ) + ); +}); diff --git a/modules/assets/src/api/domain/value-objects/support-values.ts b/modules/assets/src/api/domain/value-objects/support-values.ts new file mode 100644 index 0000000..aa501f0 --- /dev/null +++ b/modules/assets/src/api/domain/value-objects/support-values.ts @@ -0,0 +1,37 @@ +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { SupportError, type SupportInput, SupportValuesSchema } from "../support-values"; +export class SupportValues { + private constructor( + public readonly toolId: UniqueID, + private readonly data: Omit + ) { + Object.freeze(data); + Object.freeze(this); + } + static create(input: SupportInput): Result { + const parsed = SupportValuesSchema.safeParse(input); + const tool = UniqueID.create(input.toolId); + if (!(parsed.success && tool.isSuccess)) { + return Result.fail(new SupportError("INVALID_REQUEST")); + } + const { toolId: _, ...data } = parsed.data; + return Result.ok(new SupportValues(tool.data, data)); + } + toPrimitive(): SupportInput { + return { ...this.data, toolId: this.toolId.toPrimitive() }; + } + equals(other: SupportValues) { + const a = this.data; + const b = other.data; + return ( + this.toolId.equals(other.toolId) && + a.destination === b.destination && + a.requiresVpn === b.requiresVpn && + a.preferred === b.preferred && + a.state === b.state && + a.notes === b.notes + ); + } +} diff --git a/modules/assets/src/api/infrastructure/http/support.routes.ts b/modules/assets/src/api/infrastructure/http/support.routes.ts new file mode 100644 index 0000000..dabbfe9 --- /dev/null +++ b/modules/assets/src/api/infrastructure/http/support.routes.ts @@ -0,0 +1,110 @@ +import type { Application, RequestHandler, Response } from "express"; +import { Router } from "express"; +import { z } from "zod/v4"; + +import { SupportToolInputSchema, SupportValuesSchema } from "../../../common/support"; +import type { SupportUseCases } from "../../application/support.use-cases"; +import { buildSupport, buildSupportTool } from "../../application/support-response.builder"; + +function fail(response: Response, code: string) { + response + .status(code === "INVALID_REQUEST" ? 400 : code.endsWith("NOT_FOUND") ? 404 : 409) + .json({ code, requestId: response.getHeader("x-request-id") }); +} +const Params = z.strictObject({ companyId: z.uuid(), assetId: z.uuid() }); +const Empty = z.strictObject({}); +export function registerSupportRoutes( + app: Application, + useCases: SupportUseCases, + authenticate: RequestHandler +) { + const router = Router(); + router.get("/tools", (request, response, next) => { + if (!Empty.safeParse(request.query).success) { + fail(response, "INVALID_REQUEST"); + return; + } + useCases + .tools() + .then((tools) => response.json({ tools: tools.map(buildSupportTool) })) + .catch(next); + }); + router.get("/:companyId/:assetId", (request, response, next) => { + const params = Params.safeParse(request.params); + if (!(params.success && Empty.safeParse(request.query).success)) { + fail(response, "INVALID_REQUEST"); + return; + } + useCases + .get(params.data.companyId, params.data.assetId) + .then((result) => { + if (!result.isSuccess) { + fail(response, result.error.code); + return; + } + response.json({ + connections: result.data.connections.map((item) => + buildSupport(item, result.data.actorNames) + ), + tools: result.data.tools.map(buildSupportTool), + }); + }) + .catch(next); + }); + for (const path of ["/tools", "/tools/:id/update"]) { + router.post(path, (request, response, next) => { + const params = (path.endsWith("update") ? z.strictObject({ id: z.uuid() }) : Empty).safeParse( + request.params + ); + const body = z.strictObject({ values: SupportToolInputSchema }).safeParse(request.body); + if (!(params.success && body.success && Empty.safeParse(request.query).success)) { + fail(response, "INVALID_REQUEST"); + return; + } + const id = + "id" in params.data && typeof params.data.id === "string" ? params.data.id : undefined; + useCases + .saveTool(id, body.data.values) + .then((result) => { + if (!result.isSuccess) { + fail(response, result.error.code); + return; + } + response.status(id ? 200 : 201).json({ tool: buildSupportTool(result.data) }); + }) + .catch(next); + }); + } + for (const path of ["/:companyId/:assetId", "/:companyId/:assetId/:id/update"]) { + router.post(path, (request, response, next) => { + const params = (path.endsWith("update") ? Params.extend({ id: z.uuid() }) : Params).safeParse( + request.params + ); + const body = z.strictObject({ values: SupportValuesSchema }).safeParse(request.body); + const actor = z + .object({ userId: z.uuid() }) + .safeParse("session" in request ? request.session : undefined); + if ( + !(params.success && body.success && actor.success && Empty.safeParse(request.query).success) + ) { + fail(response, "INVALID_REQUEST"); + return; + } + const id = + "id" in params.data && typeof params.data.id === "string" ? params.data.id : undefined; + useCases + .save(params.data.companyId, params.data.assetId, id, body.data.values, actor.data.userId) + .then((result) => { + if (!result.isSuccess) { + fail(response, result.error.code); + return; + } + response + .status(id ? 200 : 201) + .json({ connection: buildSupport(result.data.connection, result.data.actorNames) }); + }) + .catch(next); + }); + } + app.use("/api/asset-support", authenticate, router); +} diff --git a/modules/assets/src/api/infrastructure/persistence/sequelize-support.repository.ts b/modules/assets/src/api/infrastructure/persistence/sequelize-support.repository.ts new file mode 100644 index 0000000..fbab795 --- /dev/null +++ b/modules/assets/src/api/infrastructure/persistence/sequelize-support.repository.ts @@ -0,0 +1,130 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import { QueryTypes, type Sequelize, type Transaction } from "sequelize"; + +import type { ISupportRepository, SupportContext } from "../../application/support.repository"; +import { AssetInputSchema } from "../../domain/asset-values"; +import type { SupportConnection } from "../../domain/support-connection.aggregate"; +import type { SupportTool } from "../../domain/support-tool.aggregate"; +import { SupportError } from "../../domain/support-values"; + +import { SupportPersistenceMapper } from "./support-persistence.mapper"; +export class SequelizeSupportRepository implements ISupportRepository { + private readonly mapper = new SupportPersistenceMapper(); + constructor(private readonly database: Sequelize) {} + private async readTools(transaction?: Transaction) { + const rows = await this.database.query>( + "SELECT id,name,active,builtin FROM support_tools ORDER BY name", + { type: QueryTypes.SELECT, ...(transaction ? { transaction } : {}) } + ); + return rows.map((row) => this.mapper.toolToDomain(row)); + } + tools() { + return this.readTools(); + } + get(company: UniqueID, asset: UniqueID) { + return this.database.transaction((transaction) => this.read(company, asset, transaction)); + } + private async read( + company: UniqueID, + asset: UniqueID, + transaction: Transaction + ): Promise> { + const replacements = { companyId: company.toPrimitive(), assetId: asset.toPrimitive() }; + const assets = await this.database.query<{ values: unknown }>( + "SELECT `values` FROM assets WHERE companyId=:companyId AND id=:assetId", + { replacements, transaction, type: QueryTypes.SELECT } + ); + if (!assets[0]) { + return Result.fail(new SupportError("ASSET_NOT_FOUND")); + } + const assetValues = + typeof assets[0].values === "string" ? JSON.parse(assets[0].values) : assets[0].values; + const assetInput = AssetInputSchema.parse(assetValues); + const roots = await this.database.query>( + "SELECT id,companyId,assetId,toolId,`values`,preferredKey FROM asset_support WHERE companyId=:companyId AND assetId=:assetId", + { replacements, transaction, type: QueryTypes.SELECT } + ); + const revisions = await this.database.query>( + "SELECT r.id,r.connectionId,r.actorId,r.toolId,r.`values`,r.recordedAt,r.position FROM asset_support_revisions r JOIN asset_support c ON c.id=r.connectionId WHERE c.companyId=:companyId AND c.assetId=:assetId", + { replacements, transaction, type: QueryTypes.SELECT } + ); + const users = await this.database.query<{ id: string; name: string }>( + "SELECT id,name FROM users", + { transaction, type: QueryTypes.SELECT } + ); + return Result.ok({ + assetRetired: assetInput.status === "retired", + tools: await this.readTools(transaction), + connections: roots.map((root) => + this.mapper.toDomain( + root, + revisions.filter((row) => row.connectionId === root.id) + ) + ), + actorNames: new Map(users.map((item) => [item.id, item.name])), + }); + } + async save( + company: UniqueID, + asset: UniqueID, + action: (context: SupportContext) => Result + ) { + return this.database.transaction(async (transaction) => { + await this.database.query("SELECT id FROM companies WHERE id=:id FOR UPDATE", { + replacements: { id: company.toPrimitive() }, + transaction, + type: QueryTypes.SELECT, + }); + const context = await this.read(company, asset, transaction); + if (!context.isSuccess) { + return Result.fail(context.error); + } + const result = action(context.data); + if (!result.isSuccess) { + return Result.fail(result.error); + } + const data = this.mapper.toPersistence(result.data); + await this.database.query( + context.data.connections.some((item) => item.id.equals(result.data.id)) + ? "UPDATE asset_support SET toolId=:toolId,`values`=:values,preferredKey=:preferredKey WHERE id=:id" + : "INSERT INTO asset_support (id,companyId,assetId,toolId,`values`,preferredKey) VALUES (:id,:companyId,:assetId,:toolId,:values,:preferredKey)", + { replacements: data.root, transaction } + ); + const existing = new Set( + context.data.connections + .find((item) => item.id.equals(result.data.id)) + ?.history.map((item) => item.id.toPrimitive()) ?? [] + ); + for (const row of data.revisions) { + if (!existing.has(row.id)) { + await this.database.query( + "INSERT INTO asset_support_revisions (id,connectionId,actorId,toolId,`values`,recordedAt,position) VALUES (:id,:connectionId,:actorId,:toolId,:values,:recordedAt,:position)", + { replacements: row, transaction } + ); + } + } + return Result.ok({ connection: result.data, actorNames: context.data.actorNames }); + }); + } + saveTool(action: (tools: readonly SupportTool[]) => Result) { + return this.database.transaction(async (transaction) => { + await this.database.query("SELECT id FROM support_catalog_lock WHERE id=1 FOR UPDATE", { + transaction, + type: QueryTypes.SELECT, + }); + const tools = await this.readTools(transaction); + const result = action(tools); + if (!result.isSuccess) { + return result; + } + await this.database.query( + tools.some((tool) => tool.id.equals(result.data.id)) + ? "UPDATE support_tools SET name=:name,active=:active WHERE id=:id" + : "INSERT INTO support_tools (id,name,active,builtin) VALUES (:id,:name,:active,:builtin)", + { replacements: this.mapper.toolToPersistence(result.data), transaction } + ); + return result; + }); + } +} diff --git a/modules/assets/src/api/infrastructure/persistence/support-migration.ts b/modules/assets/src/api/infrastructure/persistence/support-migration.ts new file mode 100644 index 0000000..798e84d --- /dev/null +++ b/modules/assets/src/api/infrastructure/persistence/support-migration.ts @@ -0,0 +1,73 @@ +import { UniqueID } from "@repo/rdx-ddd"; +import { DataTypes, QueryTypes, type Sequelize } from "sequelize"; +export async function migrateSupport(database: Sequelize) { + const query = database.getQueryInterface(); + const tables = await database.query<{ name: string }>( + "SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()", + { type: QueryTypes.SELECT } + ); + const fk = (model: string) => ({ + type: DataTypes.UUID, + allowNull: false, + references: { model, key: "id" }, + onDelete: "RESTRICT", + }); + const definitions = [ + ["support_catalog_lock", { id: { type: DataTypes.INTEGER, primaryKey: true } }], + [ + "support_tools", + { + id: { type: DataTypes.UUID, primaryKey: true }, + name: { type: DataTypes.STRING(100), allowNull: false, unique: true }, + active: { type: DataTypes.BOOLEAN, allowNull: false }, + builtin: { type: DataTypes.BOOLEAN, allowNull: false }, + }, + ], + [ + "asset_support", + { + id: { type: DataTypes.UUID, primaryKey: true }, + companyId: fk("companies"), + assetId: fk("assets"), + toolId: fk("support_tools"), + values: { type: DataTypes.JSON, allowNull: false }, + preferredKey: { type: DataTypes.UUID, allowNull: true, unique: true }, + }, + ], + [ + "asset_support_revisions", + { + id: { type: DataTypes.UUID, primaryKey: true }, + connectionId: fk("asset_support"), + actorId: fk("users"), + toolId: fk("support_tools"), + values: { type: DataTypes.JSON, allowNull: false }, + recordedAt: { type: DataTypes.STRING(30), allowNull: false }, + position: { type: DataTypes.INTEGER, allowNull: false }, + }, + ], + ] as const; + for (const [table, columns] of definitions) { + if (!tables.some((item) => item.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='asset_support'", + { type: QueryTypes.SELECT } + ); + if (!constraints.some((item) => item.name === "support_asset_company")) { + await database.query( + "ALTER TABLE asset_support ADD CONSTRAINT support_asset_company FOREIGN KEY (companyId,assetId) REFERENCES assets(companyId,id)" + ); + } + await database.query("INSERT IGNORE INTO support_catalog_lock (id) VALUES (1)"); + for (const name of ["AnyDesk", "Supremo Control", "Escritorio remoto", "TeamViewer"]) { + await database.query( + "INSERT IGNORE INTO support_tools (id,name,active,builtin) VALUES (:id,:name,1,1)", + { + replacements: { id: UniqueID.generateNewID().toPrimitive(), name }, + } + ); + } +} diff --git a/modules/assets/src/api/infrastructure/persistence/support-persistence.mapper.ts b/modules/assets/src/api/infrastructure/persistence/support-persistence.mapper.ts new file mode 100644 index 0000000..9f8d2f7 --- /dev/null +++ b/modules/assets/src/api/infrastructure/persistence/support-persistence.mapper.ts @@ -0,0 +1,140 @@ +import { Name, UniqueID } from "@repo/rdx-ddd"; +import { z } from "zod/v4"; + +import { SupportConnection } from "../../domain/support-connection.aggregate"; +import { SupportConnectionRevision } from "../../domain/support-connection-revision.entity"; +import { SupportTool } from "../../domain/support-tool.aggregate"; +import { SupportValuesSchema } from "../../domain/support-values"; +import { SupportValues } from "../../domain/value-objects/support-values"; + +const RootSchema = z.strictObject({ + id: z.uuid(), + companyId: z.uuid(), + assetId: z.uuid(), + toolId: z.uuid(), + values: SupportValuesSchema, + preferredKey: z.uuid().nullable(), +}); +const RevisionSchema = z.strictObject({ + id: z.uuid(), + connectionId: z.uuid(), + actorId: z.uuid(), + toolId: z.uuid(), + values: SupportValuesSchema, + recordedAt: z.iso.datetime(), + position: z.number().int().nonnegative(), +}); +export function supportIdentity(value: string) { + const id = UniqueID.create(value); + if (!id.isSuccess) { + throw new Error("Identidad de asistencia inconsistente."); + } + return id.data; +} +function json(value: unknown): unknown { + return typeof value === "string" ? JSON.parse(value) : value; +} +function values(value: unknown) { + const parsed = SupportValuesSchema.parse(json(value)); + const result = SupportValues.create(parsed); + if (!result.isSuccess) { + throw new Error("Valores de asistencia inconsistentes."); + } + return result.data; +} +export class SupportPersistenceMapper { + toDomain(root: Record, rows: readonly Record[]) { + const current = RootSchema.parse({ ...root, values: json(root.values) }); + const history = rows + .map((row) => RevisionSchema.parse({ ...row, values: json(row.values) })) + .sort((a, b) => a.position - b.position); + if ( + !history.length || + new Set(history.map((item) => item.id)).size !== history.length || + history.some( + (item, index) => + item.connectionId !== current.id || + item.position !== index || + item.toolId !== item.values.toolId || + (index > 0 && item.recordedAt < (history[index - 1]?.recordedAt ?? "")) + ) + ) { + throw new Error("Historial de asistencia inconsistente."); + } + const currentValues = values(current.values); + const revisions = history.map((item) => + SupportConnectionRevision.rehydrate( + { + values: values(item.values), + actorId: supportIdentity(item.actorId), + recordedAt: item.recordedAt, + }, + supportIdentity(item.id) + ) + ); + if ( + current.toolId !== current.values.toolId || + current.preferredKey !== (current.values.preferred ? current.assetId : null) || + !revisions.at(-1)?.values.equals(currentValues) + ) { + throw new Error("Asistencia inconsistente."); + } + return SupportConnection.rehydrate( + { + companyId: supportIdentity(current.companyId), + assetId: supportIdentity(current.assetId), + values: currentValues, + history: revisions, + }, + supportIdentity(current.id) + ); + } + toPersistence(connection: SupportConnection) { + const input = connection.values.toPrimitive(); + return { + root: { + id: connection.id.toPrimitive(), + companyId: connection.companyId.toPrimitive(), + assetId: connection.assetId.toPrimitive(), + toolId: input.toolId, + values: JSON.stringify(input), + preferredKey: input.preferred ? connection.assetId.toPrimitive() : null, + }, + revisions: connection.history.map((item, position) => ({ + id: item.id.toPrimitive(), + connectionId: connection.id.toPrimitive(), + actorId: item.actorId.toPrimitive(), + toolId: item.values.toolId.toPrimitive(), + values: JSON.stringify(item.values.toPrimitive()), + recordedAt: item.recordedAt, + position, + })), + }; + } + toolToDomain(row: Record) { + const parsed = z + .strictObject({ + id: z.uuid(), + name: z.string(), + active: z.union([z.boolean(), z.literal(0), z.literal(1)]), + builtin: z.union([z.boolean(), z.literal(0), z.literal(1)]), + }) + .parse(row); + const name = Name.create(parsed.name, 100); + if (!name.isSuccess) { + throw new Error("Herramienta inconsistente."); + } + return SupportTool.rehydrate( + { name: name.data, active: Boolean(parsed.active), builtin: Boolean(parsed.builtin) }, + supportIdentity(parsed.id) + ); + } + toolToPersistence(tool: SupportTool) { + return { + id: tool.id.toPrimitive(), + name: tool.name.toPrimitive(), + active: tool.active, + builtin: tool.builtin, + }; + } +} diff --git a/modules/assets/src/api/module.ts b/modules/assets/src/api/module.ts index 25073a8..ea57310 100644 --- a/modules/assets/src/api/module.ts +++ b/modules/assets/src/api/module.ts @@ -6,11 +6,13 @@ import type { Sequelize } from "sequelize"; import { AssetUseCases } from "./application/asset.use-cases"; import { AssetCatalogUseCases } from "./application/asset-catalog.use-cases"; import { AssetRelationshipUseCases } from "./application/asset-relationship.use-cases"; +import { SupportUseCases } from "./application/support.use-cases"; import { WorkstationUseCases } from "./application/workstation.use-cases"; import { seedAssetCatalog } from "./domain/asset-catalog-seed"; import { registerAssetRoutes } from "./infrastructure/http/asset.routes"; import { registerAssetCatalogRoutes } from "./infrastructure/http/asset-catalog.routes"; import { registerRelationshipRoutes } from "./infrastructure/http/asset-relationship.routes"; +import { registerSupportRoutes } from "./infrastructure/http/support.routes"; import { registerWorkstationRoutes } from "./infrastructure/http/workstation.routes"; import { migrateInventory } from "./infrastructure/persistence/asset.model"; import { @@ -21,6 +23,8 @@ import { AssetCatalogPersistenceMapper } from "./infrastructure/persistence/asse import { migrateRelationships } from "./infrastructure/persistence/asset-relationship.model"; import { SequelizeAssetRepository } from "./infrastructure/persistence/sequelize-asset.repository"; import { SequelizeAssetCatalogRepository } from "./infrastructure/persistence/sequelize-asset-catalog.repository"; +import { SequelizeSupportRepository } from "./infrastructure/persistence/sequelize-support.repository"; +import { migrateSupport } from "./infrastructure/persistence/support-migration"; import { migrateWorkstations } from "./infrastructure/persistence/workstation.model"; export async function createAssetModule(database: Sequelize, authenticate: RequestHandler) { await migrateAssetCatalog(database); @@ -41,6 +45,8 @@ export async function createAssetModule(database: Sequelize, authenticate: Reque await migrateInventory(database); await migrateRelationships(database); await migrateWorkstations(database); + await migrateSupport(database); + const support = new SupportUseCases(new SequelizeSupportRepository(database)); const assetRepository = new SequelizeAssetRepository(database); const inventory = new AssetUseCases(assetRepository); const workstations = new WorkstationUseCases(assetRepository); @@ -68,6 +74,7 @@ export async function createAssetModule(database: Sequelize, authenticate: Reque registerAssetRoutes(app, inventory, authenticate); registerRelationshipRoutes(app, relationships, authenticate); registerWorkstationRoutes(app, workstations, authenticate); + registerSupportRoutes(app, support, authenticate); }, }; } diff --git a/modules/assets/src/common/index.ts b/modules/assets/src/common/index.ts index ef98a05..38ea5bf 100644 --- a/modules/assets/src/common/index.ts +++ b/modules/assets/src/common/index.ts @@ -29,4 +29,5 @@ export type SaveDefinitionResponseDTO = z.infer; +export type SupportToolDTO = z.infer; +export type SupportListDTO = z.infer; diff --git a/modules/assets/src/web/asset-support.tsx b/modules/assets/src/web/asset-support.tsx new file mode 100644 index 0000000..04da6ea --- /dev/null +++ b/modules/assets/src/web/asset-support.tsx @@ -0,0 +1,198 @@ +import { Button } from "@repo/shadcn-ui/components/button"; +import { FormDialog } from "@repo/shadcn-ui/components/form-dialog"; +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import type { SupportConnectionDTO, SupportToolDTO } from "../common/support"; + +import { supportApi, supportKeys } from "./support-api"; +import { SupportForm } from "./support-form"; +import { SupportTools } from "./support-tools"; +export function AssetSupport({ + companyId, + assetId, + csrfToken, + retired = false, +}: { + companyId: string; + assetId: string; + csrfToken: string; + retired?: boolean; +}) { + const { t } = useTranslation("assets"); + const query = useQuery({ + queryKey: supportKeys.asset(companyId, assetId), + queryFn: () => supportApi.get(companyId, assetId), + }); + const [open, setOpen] = useState(false); + const [edit, setEdit] = useState(); + const [toolsOpen, setToolsOpen] = useState(false); + const [showRetired, setShowRetired] = useState(false); + const [history, setHistory] = useState(); + const items = + query.data?.connections.filter((item) => showRetired || item.values.state !== "retired") ?? []; + const tools = query.data?.tools ?? []; + return ( +
+
+

{t("support.title")}

+
+ + +
+
+ {query.isPending &&

{t("support.loading")}

} + {query.isError && ( +
+

{t("support.loadError")}

+ +
+ )} + {query.data && ( + <> + + {!items.length &&

{t("support.empty")}

} +
    + {[...items] + .sort((a, b) => Number(b.values.preferred) - Number(a.values.preferred)) + .map((item) => ( +
  • + + {item.values.state !== "retired" && + (retired || !tools.find((tool) => tool.id === item.values.toolId)?.active) && ( +

    {t("support.review")}

    + )} +
    + + +
    +
  • + ))} +
+ + )} + + setOpen(false)} + /> + + + + + { + if (!value) { + setHistory(undefined); + } + }} + open={Boolean(history)} + title={t("support.history")} + > + {history && } + +
+ ); +} +export function SupportSummary({ + values, + tools, +}: { + values: SupportConnectionDTO["values"]; + tools: readonly SupportToolDTO[]; +}) { + const { t } = useTranslation("assets"); + return ( +
+

+ {tools.find((tool) => tool.id === values.toolId)?.name ?? values.toolId} ·{" "} + {t(`support.${values.state}`)} + {values.preferred ? ` · ${t("support.preferred")}` : ""} +

+ {values.destination &&

{values.destination}

} +

+ {t("support.requiresVpn")}: {t(values.requiresVpn ? "yes" : "no")} +

+ {values.notes &&

{values.notes}

} +
+ ); +} +export function SupportHistory({ + connection, + tools, +}: { + connection: SupportConnectionDTO; + tools: readonly SupportToolDTO[]; +}) { + const { i18n } = useTranslation("assets"); + return ( +
    + {[...connection.history].reverse().map((revision) => ( +
  1. +

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

    + +
  2. + ))} +
+ ); +} diff --git a/modules/assets/src/web/i18n.ts b/modules/assets/src/web/i18n.ts index f227390..119d96b 100644 --- a/modules/assets/src/web/i18n.ts +++ b/modules/assets/src/web/i18n.ts @@ -1,7 +1,9 @@ import { inventoryEnglish, inventorySpanish } from "./inventory-i18n"; import { relationshipsEnglish, relationshipsSpanish } from "./relationship-i18n"; +import { supportEnglish, supportSpanish } from "./support-i18n"; import { workstationsEnglish, workstationsSpanish } from "./workstation-i18n"; export const assetsSpanish = { + support: supportSpanish, ...inventorySpanish, ...workstationsSpanish, ...relationshipsSpanish, @@ -106,6 +108,7 @@ export const assetsSpanish = { }, }; export const assetsEnglish = { + support: supportEnglish, ...inventoryEnglish, ...workstationsEnglish, ...relationshipsEnglish, diff --git a/modules/assets/src/web/index.ts b/modules/assets/src/web/index.ts index 4666e03..71676d0 100644 --- a/modules/assets/src/web/index.ts +++ b/modules/assets/src/web/index.ts @@ -1,4 +1,5 @@ export { AssetForm } from "./asset-form"; +export { AssetSupport } from "./asset-support"; export { catalogApi, catalogKeys } from "./catalog-api"; export { AssetCatalogPage } from "./catalog-page"; export { assetsEnglish, assetsSpanish } from "./i18n"; diff --git a/modules/assets/src/web/inventory-page.tsx b/modules/assets/src/web/inventory-page.tsx index 8d91807..07f0fd1 100644 --- a/modules/assets/src/web/inventory-page.tsx +++ b/modules/assets/src/web/inventory-page.tsx @@ -12,6 +12,7 @@ import { AssetDetail } from "./asset-detail"; import type { InventoryLookups } from "./asset-form"; import { AssetForm, InventoryFeedback } from "./asset-form"; import { AssetRelationships } from "./asset-relationships"; +import { AssetSupport } from "./asset-support"; import { catalogApi, catalogKeys } from "./catalog-api"; import { CatalogError } from "./catalog-form"; import { inventoryApi, inventoryKeys } from "./inventory-api"; @@ -209,6 +210,12 @@ export function AssetInventoryPage({ > + )} @@ -255,6 +262,12 @@ function AssetSelectionPanel({ +