From 8c5b7b4df0f1ed0c95ece16e2365f7c354813904 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 10 Oct 2026 18:21:06 +0200 Subject: [PATCH] herramientas de soporte convertidos en recursos --- apps/server/src/identity.test.ts | 9 +- .../infrastructure/http/identity-runtime.ts | 2 +- apps/server/src/resources.integration.ts | 4 +- .../support-resource-migration.integration.ts | 149 ++++++++++++ apps/server/src/support.integration.ts | 167 ++++++++++--- .../modelo-ddd-e-identificadores.md | 2 +- docs/asistencia-tecnica-remota.md | 34 +-- docs/componentes-de-interfaz.md | 2 +- docs/inventario-de-modales.md | 3 - docs/normalizacion-de-la-persistencia.md | 2 +- docs/patron-de-listados-de-entidades.md | 1 - modules/assets/package.json | 3 +- .../api/application/support-input.mapper.ts | 6 +- .../application/support-response.builder.ts | 8 +- .../src/api/application/support.repository.ts | 10 +- .../src/api/application/support.use-cases.ts | 38 +-- .../src/api/domain/support-tool.aggregate.ts | 58 ----- .../assets/src/api/domain/support-values.ts | 9 +- modules/assets/src/api/domain/support.test.ts | 15 +- .../domain/value-objects/support-values.vo.ts | 12 +- .../api/infrastructure/http/support.routes.ts | 41 +--- .../sequelize-support.repository.ts | 64 ++--- .../persistence/support-migration.ts | 52 ++-- .../persistence/support-persistence.mapper.ts | 43 +--- modules/assets/src/common/locales/en.json | 14 +- modules/assets/src/common/locales/es.json | 14 +- modules/assets/src/common/support.ts | 11 +- .../web/shared/queries/support-query-keys.ts | 1 - .../detail/ui/support-summary.tsx | 2 +- .../use-asset-support-controller.ts | 3 - .../list/ui/asset-support.tsx | 10 +- .../use-support-form-controller.ts | 2 +- .../shared/support.test.tsx | 10 +- .../shared/ui/support-form.tsx | 14 +- .../shared/ui/support-help.tsx | 12 +- .../ui/support-resource-create-dialog.tsx} | 24 +- modules/assets/src/web/support-tools/index.ts | 3 - .../src/web/support-tools/list/api/index.ts | 1 - .../list/api/list-support-tools.api.ts | 6 - .../support-tools/list/controllers/index.ts | 1 - .../use-support-tools-controller.ts | 14 -- .../src/web/support-tools/list/index.ts | 1 - .../src/web/support-tools/list/ui/index.ts | 1 - .../list/ui/support-tools-dialog.tsx | 25 -- .../support-tools/list/ui/support-tools.tsx | 108 --------- .../src/web/support-tools/shared/api/index.ts | 1 - .../shared/api/save-support-tool.api.ts | 9 - .../support-tools/shared/controllers/index.ts | 1 - .../use-support-tool-form-controller.ts | 30 --- .../src/web/support-tools/shared/index.ts | 1 - .../web/support-tools/shared/types/index.ts | 1 - .../shared/types/support-tool-form-props.ts | 6 - .../src/web/support-tools/shared/ui/index.ts | 3 - .../shared/ui/support-tool-form.tsx | 54 ----- .../application-configuration.repository.ts | 2 +- .../certificate-configuration.repository.ts | 2 +- .../application/resource-inventory.service.ts | 26 +- .../src/api/domain/resource-catalog-seed.ts | 2 + .../src/api/domain/resource-catalog.test.ts | 4 +- .../src/api/domain/resource-definition.ts | 2 +- .../persistence/migrate-support-resources.ts | 222 ++++++++++++++++++ modules/resources/src/api/module.ts | 2 + modules/resources/src/common/locales/en.json | 7 +- modules/resources/src/common/locales/es.json | 7 +- .../shared/ui/catalog-template-form.tsx | 4 +- .../use-catalog-page-controller.ts | 5 +- .../shared/controllers/use-catalog-save.ts | 5 +- .../use-resource-form-controller.ts | 5 +- .../shared/helpers/resource-form-values.ts | 2 +- .../web/resources/shared/resources.test.tsx | 24 ++ .../shared/types/resource-editor-props.ts | 2 +- .../shared/types/resource-form-props.ts | 2 +- pnpm-lock.yaml | 3 + 73 files changed, 736 insertions(+), 709 deletions(-) create mode 100644 apps/server/src/support-resource-migration.integration.ts delete mode 100644 modules/assets/src/api/domain/support-tool.aggregate.ts rename modules/assets/src/web/{support-tools/shared/ui/support-tool-editor-dialog.tsx => support-connections/shared/ui/support-resource-create-dialog.tsx} (51%) delete mode 100644 modules/assets/src/web/support-tools/index.ts delete mode 100644 modules/assets/src/web/support-tools/list/api/index.ts delete mode 100644 modules/assets/src/web/support-tools/list/api/list-support-tools.api.ts delete mode 100644 modules/assets/src/web/support-tools/list/controllers/index.ts delete mode 100644 modules/assets/src/web/support-tools/list/controllers/use-support-tools-controller.ts delete mode 100644 modules/assets/src/web/support-tools/list/index.ts delete mode 100644 modules/assets/src/web/support-tools/list/ui/index.ts delete mode 100644 modules/assets/src/web/support-tools/list/ui/support-tools-dialog.tsx delete mode 100644 modules/assets/src/web/support-tools/list/ui/support-tools.tsx delete mode 100644 modules/assets/src/web/support-tools/shared/api/index.ts delete mode 100644 modules/assets/src/web/support-tools/shared/api/save-support-tool.api.ts delete mode 100644 modules/assets/src/web/support-tools/shared/controllers/index.ts delete mode 100644 modules/assets/src/web/support-tools/shared/controllers/use-support-tool-form-controller.ts delete mode 100644 modules/assets/src/web/support-tools/shared/index.ts delete mode 100644 modules/assets/src/web/support-tools/shared/types/index.ts delete mode 100644 modules/assets/src/web/support-tools/shared/types/support-tool-form-props.ts delete mode 100644 modules/assets/src/web/support-tools/shared/ui/index.ts delete mode 100644 modules/assets/src/web/support-tools/shared/ui/support-tool-form.tsx create mode 100644 modules/resources/src/api/infrastructure/persistence/migrate-support-resources.ts diff --git a/apps/server/src/identity.test.ts b/apps/server/src/identity.test.ts index 76b572d..f36c8c4 100644 --- a/apps/server/src/identity.test.ts +++ b/apps/server/src/identity.test.ts @@ -106,8 +106,6 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de 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"); @@ -926,7 +924,12 @@ 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); + await verifySupport( + (...args) => operator.request(...args), + customer.id, + secondCompany.id, + database + ); await verifyInterventionTemplates( (...args) => operator.request(...args), customer.id, diff --git a/apps/server/src/infrastructure/http/identity-runtime.ts b/apps/server/src/infrastructure/http/identity-runtime.ts index 44050e8..30de973 100644 --- a/apps/server/src/infrastructure/http/identity-runtime.ts +++ b/apps/server/src/infrastructure/http/identity-runtime.ts @@ -24,8 +24,8 @@ export async function createIdentityRuntime( identity.authenticate, options.legacyResourceFixtures === true && environment.NODE_ENV === "test" ); - const assets = await createAssetModule(database, identity.authenticate); const resources = await createResourceModule(database, identity.authenticate); + const assets = await createAssetModule(database, identity.authenticate); const mail = await companies.initializeMail(assets.services.inventory); const profiles = await companies.initializeProfiles( assets.services.inventory, diff --git a/apps/server/src/resources.integration.ts b/apps/server/src/resources.integration.ts index fd1710b..173c157 100644 --- a/apps/server/src/resources.integration.ts +++ b/apps/server/src/resources.integration.ts @@ -58,8 +58,8 @@ export async function verifyResources( const initial = await read(global); - assert.equal(initial.items.length, 26); - assert.equal(initial.items.filter((item) => item.values.kind === "template").length, 6); + assert.equal(initial.items.length, 27); + assert.equal(initial.items.filter((item) => item.values.kind === "template").length, 7); assert.ok(initial.items.every((item) => item.builtin && item.id[14] === "7")); assert.ok( initial.items.every((item) => diff --git a/apps/server/src/support-resource-migration.integration.ts b/apps/server/src/support-resource-migration.integration.ts new file mode 100644 index 0000000..2ad226b --- /dev/null +++ b/apps/server/src/support-resource-migration.integration.ts @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; + +import type { Sequelize } from "sequelize"; +import { DataTypes, QueryTypes } from "sequelize"; + +import { migrateSupport } from "../../../modules/assets/src/api/infrastructure/persistence/support-migration"; +import { migrateSupportResources } from "../../../modules/resources/src/api/infrastructure/persistence/migrate-support-resources"; + +/** Comprueba la transferencia real de una conexión con cambios históricos de herramienta. */ +export async function verifySupportResourceMigration( + database: Sequelize, + companyId: string, + assetId: string +) { + assert.equal(database.getDatabaseName(), "metis_desk_test"); + const query = database.getQueryInterface(); + + await query.createTable("support_tools", { + id: { type: DataTypes.UUID, primaryKey: true }, + name: { type: DataTypes.STRING(100), allowNull: false }, + active: { type: DataTypes.BOOLEAN, allowNull: false }, + }); + const first = randomUUID(); + const last = randomUUID(); + + for (const [id, name, active] of [ + [first, "Migración asistencia A", true], + [last, "Migración asistencia B", false], + ] as const) { + if (id === last) { + continue; + } + await database.query("INSERT INTO support_tools (id,name,active) VALUES (:id,:name,:active)", { + replacements: { id, name, active }, + }); + } + for (const table of ["asset_support", "asset_support_revisions"]) { + const constraints = await database.query<{ name: string }>( + "SELECT CONSTRAINT_NAME AS name FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=:table AND COLUMN_NAME='resourceId' AND REFERENCED_TABLE_NAME IS NOT NULL", + { replacements: { table }, type: QueryTypes.SELECT } + ); + + for (const constraint of constraints) { + await query.removeConstraint(table, constraint.name); + } + await database.query(`ALTER TABLE ${table} CHANGE resourceId toolId CHAR(36) NOT NULL`); + } + const actors = await database.query<{ id: string }>( + "SELECT id FROM users ORDER BY isInitialAdmin DESC,id LIMIT 1", + { type: QueryTypes.SELECT } + ); + const actorId = actors[0]?.id; + + assert.ok(actorId); + const connectionId = randomUUID(); + const revisions = [randomUUID(), randomUUID()]; + + await database.query( + "INSERT INTO asset_support (id,companyId,assetId,toolId,destination,requiresVpn,preferred,state,notes,preferredKey) VALUES (:id,:companyId,:assetId,:toolId,'destino conservado',1,0,'available','notas conservadas',NULL)", + { replacements: { id: connectionId, companyId, assetId, toolId: last } } + ); + for (const [position, toolId] of [first, last].entries()) { + await database.query( + "INSERT INTO asset_support_revisions (id,connectionId,actorId,toolId,destination,requiresVpn,preferred,state,notes,recordedAt,position) VALUES (:id,:connectionId,:actorId,:toolId,'destino conservado',1,0,'available','notas conservadas',:recordedAt,:position)", + { + replacements: { + id: revisions[position], + connectionId, + actorId, + toolId, + recordedAt: `2026-10-10T10:0${position}:00.000Z`, + position, + }, + } + ); + } + await assert.rejects(() => migrateSupportResources(database), /sin recurso migrado/); + await database.query( + "INSERT INTO support_tools (id,name,active) VALUES (:id,'Migración asistencia B',0)", + { replacements: { id: last } } + ); + await migrateSupportResources(database); + await migrateSupport(database); + const current = await database.query<{ + resourceId: string; + destination: string; + requiresVpn: number; + notes: string; + }>("SELECT resourceId,destination,requiresVpn,notes FROM asset_support WHERE id=:id", { + replacements: { id: connectionId }, + type: QueryTypes.SELECT, + }); + const history = await database.query<{ + id: string; + resourceId: string; + actorId: string; + recordedAt: string; + }>( + "SELECT id,resourceId,actorId,recordedAt FROM asset_support_revisions WHERE connectionId=:id ORDER BY position", + { replacements: { id: connectionId }, type: QueryTypes.SELECT } + ); + + assert.deepEqual( + history.map((item) => item.id), + revisions + ); + assert.ok(history.every((item) => item.actorId === actorId)); + assert.equal(history[0]?.recordedAt, "2026-10-10T10:00:00.000Z"); + assert.equal(current[0]?.resourceId, history[1]?.resourceId); + assert.equal(current[0]?.destination, "destino conservado"); + assert.equal(current[0]?.notes, "notas conservadas"); + assert.equal(current[0]?.requiresVpn, 1); + const names = await database.query<{ name: string; companyId: string; active: number }>( + "SELECT name,companyId,active FROM resources WHERE id IN (:ids)", + { replacements: { ids: history.map((item) => item.resourceId) }, type: QueryTypes.SELECT } + ); + + assert.ok(names.every((item) => item.companyId === companyId)); + assert.equal(names.find((item) => item.name === "Migración asistencia B")?.active, 0); + const before = await database.query("SELECT id,resourceId FROM resource_revisions ORDER BY id", { + type: QueryTypes.SELECT, + }); + + await migrateSupportResources(database); + assert.deepEqual( + await database.query("SELECT id,resourceId FROM resource_revisions ORDER BY id", { + type: QueryTypes.SELECT, + }), + before + ); + const tables = await database.query( + "SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN ('support_tools','support_catalog_lock','support_tool_resource_migrations')", + { type: QueryTypes.SELECT } + ); + + assert.deepEqual(tables, []); + await assert.rejects(() => + database.query("DELETE FROM resources WHERE id=:id", { + replacements: { id: current[0]?.resourceId }, + }) + ); + await database.query("DELETE FROM asset_support_revisions WHERE connectionId=:id", { + replacements: { id: connectionId }, + }); + await database.query("DELETE FROM asset_support WHERE id=:id", { + replacements: { id: connectionId }, + }); +} diff --git a/apps/server/src/support.integration.ts b/apps/server/src/support.integration.ts index 80ee310..f9cf5d0 100644 --- a/apps/server/src/support.integration.ts +++ b/apps/server/src/support.integration.ts @@ -5,31 +5,58 @@ import { InventoryResponseSchema, SupportListSchema, SupportResponseSchema, - SupportToolResponseSchema, - SupportToolsSchema, } from "@metis/assets/common"; +import { + CatalogResponseSchema, + ResourceResponseSchema, + SaveDefinitionResponseSchema, +} from "@metis/resources/common"; +import type { Sequelize } from "sequelize"; import { z } from "zod/v4"; +import { verifySupportResourceMigration } from "./support-resource-migration.integration"; + type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise; -export async function verifySupport(request: Request, companyId: string, otherId: string) { +export async function verifySupport( + request: Request, + companyId: string, + otherId: string, + database: Sequelize +) { 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"); + await verifySupportResourceMigration(database, companyId, host.id); + const catalog = CatalogResponseSchema.parse( + await (await request(`/resource-catalog/${companyId}`)).json() + ); + const template = catalog.items.find((item) => item.builtinKey === "template:support"); + + assert.ok(template); + const tools = []; + + for (const name of ["AnyDesk", "Supremo Control", "Escritorio remoto", "TeamViewer"]) { + const response = await request(`/resources/${companyId}`, { + values: { name, templateId: template.id, active: true, notes: "", characteristics: {} }, + }); + + assert.equal(response.status, 201, await response.clone().text()); + const resource = ResourceResponseSchema.parse(await response.json()).resource; + + tools.push({ id: resource.id, name, active: true }); + } + const tool = tools[0]; 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 available = SupportListSchema.parse(await (await request(path)).json()); + + assert.ok(tools.every((tool) => available.tools.some((item) => item.id === tool.id))); const values = { - toolId: tool.id, + resourceId: tool.id, destination: "soporte-test", requiresVpn: false, preferred: true, @@ -61,7 +88,7 @@ export async function verifySupport(request: Request, companyId: string, otherId await error(path, { values }, 409, "PREFERRED_EXISTS"); const secondValues = { ...values, - toolId: tools.find((item) => item.name === "Supremo Control")?.id, + resourceId: tools.find((item) => item.name === "Supremo Control")?.id, preferred: false, }; const second = await request(path, { values: secondValues }); @@ -100,39 +127,107 @@ export async function verifySupport(request: Request, companyId: string, otherId 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 }, + const custom = await request(`/resources/${companyId}`, { + values: { + name: "RustDesk test", + templateId: template.id, + active: true, + notes: "", + characteristics: {}, + }, }); assert.equal(custom.status, 201); - const customTool = SupportToolResponseSchema.parse(await custom.json()).tool; + const customTool = ResourceResponseSchema.parse(await custom.json()).resource; + const disabled = await request(`/resources/${companyId}/${customTool.id}/update`, { + values: { ...customTool.values, active: false }, + }); - 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 - ); + assert.equal(disabled.status, 200); await error( path, - { values: { ...values, toolId: customTool.id, preferred: false } }, + { values: { ...values, resourceId: customTool.id, preferred: false } }, 409, "TOOL_UNAVAILABLE" ); + const foreign = ResourceResponseSchema.parse( + await ( + await request(`/resources/${otherId}`, { + values: { + name: "AnyDesk ajeno", + templateId: template.id, + active: true, + notes: "", + characteristics: {}, + }, + }) + ).json() + ).resource; + + await error( + path, + { values: { ...values, resourceId: foreign.id, preferred: false } }, + 409, + "TOOL_UNAVAILABLE" + ); + const generalTemplate = catalog.items.find((item) => item.builtinKey === "template:application"); + + assert.ok(generalTemplate); + const application = ResourceResponseSchema.parse( + await ( + await request(`/resources/${companyId}`, { + values: { + name: "Aplicación sin asistencia", + templateId: generalTemplate.id, + active: true, + notes: "", + characteristics: {}, + }, + }) + ).json() + ).resource; + + await error( + path, + { values: { ...values, resourceId: application.id, preferred: false } }, + 409, + "TOOL_UNAVAILABLE" + ); + await assert.rejects(() => + database.query("UPDATE asset_support SET resourceId=:resourceId WHERE id=:id", { + replacements: { resourceId: foreign.id, id: first.id }, + }) + ); + const provider = catalog.items.find((item) => item.builtinKey === "field:provider"); + + assert.ok(provider); + const customTemplateResponse = await request(`/resource-catalog/${companyId}`, { + values: { + kind: "template", + name: "Asistencia propia", + description: "", + usage: "support", + fields: [{ fieldId: provider.id, required: false }], + }, + }); + + assert.equal(customTemplateResponse.status, 201); + const customTemplate = SaveDefinitionResponseSchema.parse(await customTemplateResponse.json()); + const customResourceResponse = await request(`/resources/${companyId}`, { + values: { + name: "RustDesk propio", + templateId: customTemplate.savedId, + active: true, + notes: "", + characteristics: {}, + }, + }); + + assert.equal(customResourceResponse.status, 201); + const customResource = ResourceResponseSchema.parse(await customResourceResponse.json()).resource; + const customList = SupportListSchema.parse(await (await request(path)).json()); + + assert.ok(customList.tools.some((item) => item.id === customResource.id)); const listed = SupportListSchema.parse(await (await request(path)).json()); assert.equal(listed.connections.length, 2); diff --git a/docs/arquitectura/modelo-ddd-e-identificadores.md b/docs/arquitectura/modelo-ddd-e-identificadores.md index 1bb9342..1067cf0 100644 --- a/docs/arquitectura/modelo-ddd-e-identificadores.md +++ b/docs/arquitectura/modelo-ddd-e-identificadores.md @@ -100,7 +100,7 @@ ProcessTemplate y PersonProcess son AggregateRoot; sus revisiones y las tareas P ## 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). +SupportConnection delimita cada método de asistencia de un activo de una compañía. SupportValues encapsula la referencia UniqueID al recurso de asistencia y los valores de configuración; SupportConnectionRevision conserva las instantáneas con actor y fecha. Las herramientas son agregados Resource de la compañía, creados desde una plantilla con uso Asistencia remota; no tienen un catálogo paralelo. Todas las raíces y revisiones nuevas tienen UUID v7. Consultar [Asistencia técnica remota](../asistencia-tecnica-remota.md). ## Tickets diff --git a/docs/asistencia-tecnica-remota.md b/docs/asistencia-tecnica-remota.md index 10b969f..a0498ab 100644 --- a/docs/asistencia-tecnica-remota.md +++ b/docs/asistencia-tecnica-remota.md @@ -1,34 +1,38 @@ # 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. +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 el entorno actual del perfil de usuario, para su dispositivo principal. -## Datos y reglas +## Herramientas como recursos -- Herramienta del catálogo global: AnyDesk, Supremo Control, Escritorio remoto y TeamViewer vienen de serie. +El catálogo global de recursos incluye la plantilla de serie «Herramienta de asistencia remota», con los campos opcionales Proveedor, URL y Descripción. Sus recursos pertenecen a una compañía y se gestionan en Recursos, con el mismo formulario, características e historial que los demás recursos. + +Una plantilla personalizada global o de compañía puede declarar el uso «Asistencia remota». Este uso queda fijo después del alta y permite ofrecer sus recursos en los formularios de asistencia. Copiar la plantilla de serie conserva ese uso. La disponibilidad del catálogo y las excepciones por compañía siguen las reglas generales de Recursos. + +El selector solo ofrece recursos de asistencia de la misma compañía. Un recurso inactivo o cuya plantilla no está disponible no se puede elegir para una nueva conexión. Las referencias existentes permanecen consultables y pueden conservarse al editar o retirar una conexión. Las aplicaciones de uso general no aparecen en este selector. + +## Configuración de cada activo + +- Recurso de asistencia utilizado: por ejemplo «AnyDesk corporativo». - 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. +- Se permiten varios registros de una herramienta cuando tienen destinos diferentes. Se rechaza repetir recurso 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. +- Cada modificación semántica añade una revisión con UUID, actor y fecha. Guardar sin cambios no duplica el historial. La eliminación explícita de una configuración sigue la operación general de borrado con confirmació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. +Los formularios son modales. El desplegable de herramienta usa SelectWithAction para abrir el formulario reutilizable de Recursos, limitado a plantillas de asistencia. Conserva los datos del formulario padre y selecciona el recurso creado. La ayuda precede a Guardar/Cancelar. 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. +SupportConnection es AggregateRoot con SupportValues y SupportConnectionRevision como DomainEntity. La herramienta es un Resource; no existe otro agregado ni catálogo de herramientas paralelo. 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. +asset_support y asset_support_revisions referencian resources mediante resourceId. La FK compuesta (companyId, resourceId) impide utilizar un recurso de otra compañía. Las revisiones conservan sus referencias mediante FK; no se puede eliminar un recurso referenciado. Se mantienen la pertenencia del activo a la compañía, las referencias a actores, el bloqueo de compañía y la clave única del método preferido. -API autenticada /api/asset-support: -- GET /tools; POST /tools; POST /tools/:id/update. -- GET /:companyId/:assetId; POST /:companyId/:assetId. -- POST /:companyId/:assetId/:id/update. +La inicialización migra el catálogo support_tools a recursos de cada compañía existente, conservando nombres y estados. Cada herramienta obtiene un recurso por compañía. Conserva su UUID en la primera compañía disponible y genera UUID v7 en las demás. Las conexiones y revisiones mantienen UUID, destinos, VPN, estado, preferencia, notas, autores y fechas; solo se sustituye su referencia a la herramienta por la referencia al recurso correspondiente. -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. +Una tabla técnica temporal conserva la correspondencia durante la transferencia para poder reanudarla sin duplicados. Una vez completada, se retiran esa tabla, support_tools y support_catalog_lock. No se usan columnas JSON. Repetir la inicialización no crea recursos ni revisiones adicionales. Las nuevas compañías pueden registrar sus herramientas desde Recursos o desde el alta rápida de asistencia. ## 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. +Pruebas de dominio, mapper, interfaz e integración HTTP/MariaDB: varios métodos, preferido único, duplicados, historial/idempotencia, inactividad, retirada de activo, pertenencia, recursos de uso incorrecto, autenticación, CSRF y origen. La prueba de migración reconstruye una conexión con dos herramientas históricas y comprueba identidad, auditoría, valores, estado de los recursos, restricciones y retirada de tablas independientes. diff --git a/docs/componentes-de-interfaz.md b/docs/componentes-de-interfaz.md index 9bd5e48..7f8303b 100644 --- a/docs/componentes-de-interfaz.md +++ b/docs/componentes-de-interfaz.md @@ -184,7 +184,7 @@ Se reutilizan FormDialog y FormActions sin modificar su implementación vendor. específicos de entidades viven junto a sus consumidores, no en la biblioteca visual genérica. CompanyEditorDialog, PersonEditorDialog, DepartmentEditorDialog, SiteEditorDialog, -BrandEditorDialog y SupportToolEditorDialog se reutilizan donde ya existen operaciones +BrandEditorDialog y el formulario reutilizable ResourceEditor se reutilizan donde ya existen operaciones equivalentes. Los callbacks de guardado actualizan la selección del consumidor; no se cierra un formulario por el mero inicio de una mutación. Los historiales de consulta se derivan de los datos remotos actuales, mientras que los borradores de edición permanecen locales. diff --git a/docs/inventario-de-modales.md b/docs/inventario-de-modales.md index 7f798b5..eb42379 100644 --- a/docs/inventario-de-modales.md +++ b/docs/inventario-de-modales.md @@ -29,7 +29,6 @@ sus schemas, borradores, mutaciones, ayuda y errores. EndRelationshipDialog pose mutación de confirmación y sus estados pendientes y de error. CompanyEditorDialog, PersonEditorDialog, DepartmentEditorDialog y SiteEditorDialog se reutilizan -desde listados y altas rápidas equivalentes. BrandEditorDialog y SupportToolEditorDialog comparten el patrón entre catálogo y selector. Los callbacks de selección permanecen en el consumidor que posee el borrador. Las altas anidadas conservan sus restricciones existentes. @@ -57,8 +56,6 @@ Las rutas reflejan la [estructura por entidades y operaciones](estructura-web-po - [RelationshipEditorDialog](../modules/assets/src/web/asset-relationships/shared/ui/relationship-editor-dialog.tsx) - [SupportConnectionEditorDialog](../modules/assets/src/web/support-connections/shared/ui/support-connection-editor-dialog.tsx) - [SupportHistoryDialog](../modules/assets/src/web/support-connections/history/ui/support-history-dialog.tsx) -- [SupportToolEditorDialog](../modules/assets/src/web/support-tools/shared/ui/support-tool-editor-dialog.tsx) -- [SupportToolsDialog](../modules/assets/src/web/support-tools/list/ui/support-tools-dialog.tsx) - [WorkstationAssetCreateDialog](../modules/assets/src/web/assets/create/ui/workstation-asset-create-dialog.tsx) - [WorkstationEditorDialog](../modules/assets/src/web/workstations/shared/ui/workstation-editor-dialog.tsx) - [WorkstationHistoryDialog](../modules/assets/src/web/workstations/history/ui/workstation-history-dialog.tsx) diff --git a/docs/normalizacion-de-la-persistencia.md b/docs/normalizacion-de-la-persistencia.md index e2898da..b961102 100644 --- a/docs/normalizacion-de-la-persistencia.md +++ b/docs/normalizacion-de-la-persistencia.md @@ -12,7 +12,7 @@ El modelo utiliza columnas y relaciones para activos, recursos, configuraciones, | Catálogo e inventario de activos | Definiciones, campos tipados, especificaciones y revisiones en columnas y tablas | | Catálogo e inventario de recursos | Definiciones, opciones, secciones, especificaciones congeladas, valores tipados e integración en columnas/tablas | | Puestos de trabajo | Nombre, estado y referencias en columnas; activos exclusivos/compartidos por puesto y revisión en tablas | -| Asistencia técnica de activos | Destino, VPN, preferencia, estado y notas en columnas actuales e históricas | +| Asistencia técnica de activos | Referencia al recurso de asistencia, destino, VPN, preferencia, estado y notas en columnas actuales e históricas | | Perfiles de usuario | Nombre, estado, notas y referencias en columnas actuales e históricas | | Teletrabajo | Estado, periodo, notas y persona en columnas actuales e históricas | | Aplicaciones, certificados, impresoras y carpetas del perfil | Valores escalares y referencias en columnas actuales e históricas | diff --git a/docs/patron-de-listados-de-entidades.md b/docs/patron-de-listados-de-entidades.md index 493a2dd..56666d3 100644 --- a/docs/patron-de-listados-de-entidades.md +++ b/docs/patron-de-listados-de-entidades.md @@ -79,7 +79,6 @@ La aplicación navega por espacios y secciones mediante rutas URL independientes | Tickets, global/compañía | [TicketsPage](../modules/companies/src/web/tickets/list/ui/tickets-page.tsx) | Sí | | Intervenciones, global/compañía | [InterventionsPage](../modules/companies/src/web/interventions/list/ui/interventions-page.tsx) | Sí | | Marcas globales | [BrandCatalogPage](../modules/assets/src/web/brands/list/ui/brands-page.tsx) | Sí; EntityListHeader y EntityTable, filtro de estado y modales | -| Herramientas de asistencia | [SupportTools](../modules/assets/src/web/support-tools/list/ui/support-tools.tsx) | Sí; catálogo dentro de un modal | | Asignaciones departamentales | PeopleSection → PersonDetails | Sublistado de ficha; conserva sus operaciones y adopta tabla común | Los enlaces apuntan a las ubicaciones actuales bajo `modules//src/web//`. diff --git a/modules/assets/package.json b/modules/assets/package.json index d77a874..0972c45 100644 --- a/modules/assets/package.json +++ b/modules/assets/package.json @@ -23,7 +23,8 @@ "react-hook-form": "7.72.1", "saxes": "6.0.0", "sequelize": "6.37.8", - "zod": "4.3.6" + "zod": "4.3.6", + "@metis/resources": "workspace:*" }, "devDependencies": { "@repo/typescript-config": "workspace:*", diff --git a/modules/assets/src/api/application/support-input.mapper.ts b/modules/assets/src/api/application/support-input.mapper.ts index 0cbf6f6..1a74b60 100644 --- a/modules/assets/src/api/application/support-input.mapper.ts +++ b/modules/assets/src/api/application/support-input.mapper.ts @@ -17,9 +17,9 @@ export function mapSupportInput( if (!values.isSuccess) { return values; } - const tool = context.tools.find((item) => item.id.equals(values.data.toolId)); + const tool = context.tools.find((item) => item.id.equals(values.data.resourceId)); - if (!(tool && (tool.active || previous?.values.toolId.equals(tool.id)))) { + if (!(tool && (tool.active || previous?.values.resourceId.equals(tool.id)))) { return Result.fail(new SupportError("TOOL_UNAVAILABLE")); } if (context.assetRetired && input.state !== "retired") { @@ -44,7 +44,7 @@ export function mapSupportInput( return ( item.id.toPrimitive() !== previous?.id.toPrimitive() && other.state !== "retired" && - other.toolId === next.toolId && + other.resourceId === next.resourceId && other.destination.toLowerCase() === next.destination.toLowerCase() ); }) diff --git a/modules/assets/src/api/application/support-response.builder.ts b/modules/assets/src/api/application/support-response.builder.ts index 2ca7581..844a92b 100644 --- a/modules/assets/src/api/application/support-response.builder.ts +++ b/modules/assets/src/api/application/support-response.builder.ts @@ -1,13 +1,13 @@ 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 { +import type { SupportResource } from "./support.repository"; + +export function buildSupportTool(tool: SupportResource): SupportToolDTO { return { id: tool.id.toPrimitive(), - name: tool.name.toPrimitive(), + name: tool.name, active: tool.active, - builtin: tool.builtin, }; } diff --git a/modules/assets/src/api/application/support.repository.ts b/modules/assets/src/api/application/support.repository.ts index 071317c..4764416 100644 --- a/modules/assets/src/api/application/support.repository.ts +++ b/modules/assets/src/api/application/support.repository.ts @@ -2,12 +2,14 @@ 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"; + +export type SupportResource = Readonly<{ id: UniqueID; name: string; active: boolean }>; + import type { SupportError } from "../domain/support-values"; export type SupportContext = Readonly<{ connections: readonly SupportConnection[]; - tools: readonly SupportTool[]; + tools: readonly SupportResource[]; assetRetired: boolean; actorNames: ReadonlyMap; }>; @@ -22,8 +24,4 @@ export interface ISupportRepository { ): 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 index f2c1960..0ceabd0 100644 --- a/modules/assets/src/api/application/support.use-cases.ts +++ b/modules/assets/src/api/application/support.use-cases.ts @@ -2,8 +2,7 @@ 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 type { SupportInput, SupportToolInput } from "../domain/support-values"; +import type { SupportInput } from "../domain/support-values"; import { SupportError } from "../domain/support-values"; import type { ISupportRepository } from "./support.repository"; @@ -35,10 +34,6 @@ export class SupportUseCases { return this.repository.remove(company.data, asset.data, identity.data); } - tools() { - return this.repository.tools(); - } - save( companyId: string, assetId: string, @@ -78,35 +73,4 @@ export class SupportUseCases { : 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-tool.aggregate.ts b/modules/assets/src/api/domain/support-tool.aggregate.ts deleted file mode 100644 index 594e4c2..0000000 --- a/modules/assets/src/api/domain/support-tool.aggregate.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { UniqueID } from "@repo/rdx-ddd"; -import { AggregateRoot, Name } from "@repo/rdx-ddd"; -import { Result } from "@repo/rdx-utils"; - -import type { SupportToolInput } from "./support-values"; -import { SupportError, 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 index f924f58..f224cc1 100644 --- a/modules/assets/src/api/domain/support-values.ts +++ b/modules/assets/src/api/domain/support-values.ts @@ -2,7 +2,7 @@ import { z } from "zod/v4"; export const SupportValuesSchema = z .strictObject({ - toolId: z.uuid(), + resourceId: z.uuid(), destination: z.string().trim().max(300), requiresVpn: z.boolean(), preferred: z.boolean(), @@ -13,13 +13,6 @@ export const SupportValuesSchema = z 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 index 4c0afd9..e21538a 100644 --- a/modules/assets/src/api/domain/support.test.ts +++ b/modules/assets/src/api/domain/support.test.ts @@ -7,19 +7,15 @@ 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.vo"; 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 tool = { id: UniqueID.generateNewID(), name: "AnyDesk", active: true }; const input = { - toolId: tool.id.toPrimitive(), + resourceId: tool.id.toPrimitive(), destination: "123 456", requiresVpn: false, preferred: false, @@ -110,13 +106,10 @@ test("asistencia: varios métodos, duplicados, preferido único y activo retirad ); }); 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 disabled = { ...tool, active: false }; const context = { connections: [], - tools: [disabled.data], + tools: [disabled], assetRetired: false, actorNames: new Map(), }; diff --git a/modules/assets/src/api/domain/value-objects/support-values.vo.ts b/modules/assets/src/api/domain/value-objects/support-values.vo.ts index 6412ef0..ba65e47 100644 --- a/modules/assets/src/api/domain/value-objects/support-values.vo.ts +++ b/modules/assets/src/api/domain/value-objects/support-values.vo.ts @@ -6,8 +6,8 @@ import { SupportError, SupportValuesSchema } from "../support-values"; export class SupportValues { private constructor( - public readonly toolId: UniqueID, - private readonly data: Omit + public readonly resourceId: UniqueID, + private readonly data: Omit ) { Object.freeze(data); Object.freeze(this); @@ -15,18 +15,18 @@ export class SupportValues { static create(input: SupportInput): Result { const parsed = SupportValuesSchema.safeParse(input); - const tool = UniqueID.create(input.toolId); + const tool = UniqueID.create(input.resourceId); if (!(parsed.success && tool.isSuccess)) { return Result.fail(new SupportError("INVALID_REQUEST")); } - const { toolId: _, ...data } = parsed.data; + const { resourceId: _, ...data } = parsed.data; return Result.ok(new SupportValues(tool.data, data)); } toPrimitive(): SupportInput { - return { ...this.data, toolId: this.toolId.toPrimitive() }; + return { ...this.data, resourceId: this.resourceId.toPrimitive() }; } equals(other: SupportValues) { @@ -34,7 +34,7 @@ export class SupportValues { const b = other.data; return ( - this.toolId.equals(other.toolId) && + this.resourceId.equals(other.resourceId) && a.destination === b.destination && a.requiresVpn === b.requiresVpn && a.preferred === b.preferred && diff --git a/modules/assets/src/api/infrastructure/http/support.routes.ts b/modules/assets/src/api/infrastructure/http/support.routes.ts index 1109f67..40e9d2e 100644 --- a/modules/assets/src/api/infrastructure/http/support.routes.ts +++ b/modules/assets/src/api/infrastructure/http/support.routes.ts @@ -2,7 +2,7 @@ import type { Application, RequestHandler, Response } from "express"; import { Router } from "express"; import { z } from "zod/v4"; -import { SupportToolInputSchema, SupportValuesSchema } from "../../../common/support"; +import { SupportValuesSchema } from "../../../common/support"; import type { SupportUseCases } from "../../application/support.use-cases"; import { buildSupport, buildSupportTool } from "../../application/support-response.builder"; @@ -22,17 +22,6 @@ export function registerSupportRoutes( ) { 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); @@ -58,34 +47,6 @@ export function registerSupportRoutes( }) .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( diff --git a/modules/assets/src/api/infrastructure/persistence/sequelize-support.repository.ts b/modules/assets/src/api/infrastructure/persistence/sequelize-support.repository.ts index 985717f..495c3e5 100644 --- a/modules/assets/src/api/infrastructure/persistence/sequelize-support.repository.ts +++ b/modules/assets/src/api/infrastructure/persistence/sequelize-support.repository.ts @@ -1,12 +1,12 @@ -import type { UniqueID } from "@repo/rdx-ddd"; +import { 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 { ISupportRepository, SupportContext } from "../../application/support.repository"; import { AssetStatusSchema } 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"; @@ -16,17 +16,28 @@ export class SequelizeSupportRepository implements ISupportRepository { constructor(private readonly database: Sequelize) {} - private async readTools(transaction?: Transaction) { + private async readTools(company: UniqueID, transaction: Transaction) { const rows = await this.database.query>( - "SELECT id,name,active,builtin FROM support_tools ORDER BY name", - { type: QueryTypes.SELECT, ...(transaction ? { transaction } : {}) } + "SELECT r.id,r.name,(r.active AND d.active AND COALESCE(a.active,1)) AS active FROM resources r JOIN resource_definitions d ON d.id=r.templateId LEFT JOIN resource_availability a ON a.definitionId=d.id AND a.companyId=r.companyId WHERE r.companyId=:companyId AND (d.stockKey='template:support' OR d.usage='support') ORDER BY r.name", + { replacements: { companyId: company.toPrimitive() }, type: QueryTypes.SELECT, transaction } ); - return rows.map((row) => this.mapper.toolToDomain(row)); - } + return rows.map((row) => { + const parsed = z + .object({ + id: z.uuid(), + name: z.string(), + active: z.union([z.boolean(), z.literal(0), z.literal(1)]).transform(Boolean), + }) + .parse(row); + const id = UniqueID.create(parsed.id); - tools() { - return this.readTools(); + if (!id.isSuccess) { + throw new Error("Recurso de asistencia inconsistente."); + } + + return { ...parsed, id: id.data }; + }); } get(company: UniqueID, asset: UniqueID) { @@ -49,11 +60,11 @@ export class SequelizeSupportRepository implements ISupportRepository { } const assetStatus = AssetStatusSchema.parse(assets[0].status); const roots = await this.database.query>( - "SELECT id,companyId,assetId,toolId,destination,requiresVpn,preferred,state,notes,preferredKey FROM asset_support WHERE companyId=:companyId AND assetId=:assetId", + "SELECT id,companyId,assetId,resourceId,destination,requiresVpn,preferred,state,notes,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.destination,r.requiresVpn,r.preferred,r.state,r.notes,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", + "SELECT r.id,r.connectionId,r.actorId,r.resourceId,r.destination,r.requiresVpn,r.preferred,r.state,r.notes,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 }>( @@ -63,7 +74,7 @@ export class SequelizeSupportRepository implements ISupportRepository { return Result.ok({ assetRetired: assetStatus === "retired", - tools: await this.readTools(transaction), + tools: await this.readTools(company, transaction), connections: roots.map((root) => this.mapper.toDomain( root, @@ -133,8 +144,8 @@ export class SequelizeSupportRepository implements ISupportRepository { await this.database.query( context.data.connections.some((item) => item.id.equals(result.data.id)) - ? "UPDATE asset_support SET toolId=:toolId,destination=:destination,requiresVpn=:requiresVpn,preferred=:preferred,state=:state,notes=:notes,preferredKey=:preferredKey WHERE id=:id" - : "INSERT INTO asset_support (id,companyId,assetId,toolId,destination,requiresVpn,preferred,state,notes,preferredKey) VALUES (:id,:companyId,:assetId,:toolId,:destination,:requiresVpn,:preferred,:state,:notes,:preferredKey)", + ? "UPDATE asset_support SET resourceId=:resourceId,destination=:destination,requiresVpn=:requiresVpn,preferred=:preferred,state=:state,notes=:notes,preferredKey=:preferredKey WHERE id=:id" + : "INSERT INTO asset_support (id,companyId,assetId,resourceId,destination,requiresVpn,preferred,state,notes,preferredKey) VALUES (:id,:companyId,:assetId,:resourceId,:destination,:requiresVpn,:preferred,:state,:notes,:preferredKey)", { replacements: data.root, transaction } ); const existing = new Set( @@ -146,7 +157,7 @@ export class SequelizeSupportRepository implements ISupportRepository { for (const row of data.revisions) { if (!existing.has(row.id)) { await this.database.query( - "INSERT INTO asset_support_revisions (id,connectionId,actorId,toolId,destination,requiresVpn,preferred,state,notes,recordedAt,position) VALUES (:id,:connectionId,:actorId,:toolId,:destination,:requiresVpn,:preferred,:state,:notes,:recordedAt,:position)", + "INSERT INTO asset_support_revisions (id,connectionId,actorId,resourceId,destination,requiresVpn,preferred,state,notes,recordedAt,position) VALUES (:id,:connectionId,:actorId,:resourceId,:destination,:requiresVpn,:preferred,:state,:notes,:recordedAt,:position)", { replacements: row, transaction } ); } @@ -155,27 +166,4 @@ export class SequelizeSupportRepository implements ISupportRepository { 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 index 756c787..162526a 100644 --- a/modules/assets/src/api/infrastructure/persistence/support-migration.ts +++ b/modules/assets/src/api/infrastructure/persistence/support-migration.ts @@ -1,4 +1,3 @@ -import { UniqueID } from "@repo/rdx-ddd"; import type { Sequelize } from "sequelize"; import { DataTypes, QueryTypes } from "sequelize"; @@ -19,23 +18,13 @@ export async function migrateSupport(database: Sequelize) { }); 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"), + resourceId: { type: DataTypes.UUID, allowNull: false }, ...supportColumns, preferredKey: { type: DataTypes.UUID, allowNull: true, unique: true }, }, @@ -46,7 +35,7 @@ export async function migrateSupport(database: Sequelize) { id: { type: DataTypes.UUID, primaryKey: true }, connectionId: fk("asset_support"), actorId: fk("users"), - toolId: fk("support_tools"), + resourceId: { type: DataTypes.UUID, allowNull: false }, ...supportColumns, recordedAt: { type: DataTypes.STRING(30), allowNull: false }, position: { type: DataTypes.INTEGER, allowNull: false }, @@ -69,13 +58,36 @@ export async function migrateSupport(database: Sequelize) { "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 }, - } + const resourceIndexes = await database.query<{ name: string }>( + "SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='resources'", + { type: QueryTypes.SELECT } + ); + + if (!resourceIndexes.some((index) => index.name === "resource_company_identity")) { + await query.addIndex("resources", ["companyId", "id"], { + unique: true, + name: "resource_company_identity", + }); + } + for (const table of ["asset_support", "asset_support_revisions"]) { + const existing = await database.query<{ name: string }>( + "SELECT CONSTRAINT_NAME AS name FROM information_schema.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE() AND TABLE_NAME=:table", + { replacements: { table }, type: QueryTypes.SELECT } ); + const name = `${table}_resource_reference`; + + if (!existing.some((item) => item.name === name)) { + await database.query( + `ALTER TABLE ${table} ADD CONSTRAINT ${name} FOREIGN KEY (resourceId) REFERENCES resources(id) ON DELETE RESTRICT` + ); + } + if ( + table === "asset_support" && + !existing.some((item) => item.name === "support_resource_company") + ) { + await database.query( + "ALTER TABLE asset_support ADD CONSTRAINT support_resource_company FOREIGN KEY (companyId,resourceId) REFERENCES resources(companyId,id) ON DELETE RESTRICT" + ); + } } } diff --git a/modules/assets/src/api/infrastructure/persistence/support-persistence.mapper.ts b/modules/assets/src/api/infrastructure/persistence/support-persistence.mapper.ts index 72fe266..d1b8959 100644 --- a/modules/assets/src/api/infrastructure/persistence/support-persistence.mapper.ts +++ b/modules/assets/src/api/infrastructure/persistence/support-persistence.mapper.ts @@ -4,7 +4,7 @@ function connectionRow(row: Record) { return { ...identity, values: { - toolId: row.toolId, + resourceId: row.resourceId, destination, requiresVpn: z .union([z.boolean(), z.literal(0), z.literal(1)]) @@ -20,12 +20,11 @@ function connectionRow(row: Record) { }; } -import { Name, UniqueID } from "@repo/rdx-ddd"; +import { 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.vo"; @@ -33,7 +32,7 @@ const RootSchema = z.strictObject({ id: z.uuid(), companyId: z.uuid(), assetId: z.uuid(), - toolId: z.uuid(), + resourceId: z.uuid(), values: SupportValuesSchema, preferredKey: z.uuid().nullable(), }); @@ -41,7 +40,7 @@ const RevisionSchema = z.strictObject({ id: z.uuid(), connectionId: z.uuid(), actorId: z.uuid(), - toolId: z.uuid(), + resourceId: z.uuid(), values: SupportValuesSchema, recordedAt: z.iso.datetime(), position: z.number().int().nonnegative(), @@ -82,7 +81,7 @@ export class SupportPersistenceMapper { (item, index) => item.connectionId !== current.id || item.position !== index || - item.toolId !== item.values.toolId || + item.resourceId !== item.values.resourceId || (index > 0 && item.recordedAt < (history[index - 1]?.recordedAt ?? "")) ) ) { @@ -101,7 +100,7 @@ export class SupportPersistenceMapper { ); if ( - current.toolId !== current.values.toolId || + current.resourceId !== current.values.resourceId || current.preferredKey !== (current.values.preferred ? current.assetId : null) || !revisions.at(-1)?.values.equals(currentValues) ) { @@ -140,34 +139,4 @@ export class SupportPersistenceMapper { })), }; } - - 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/common/locales/en.json b/modules/assets/src/common/locales/en.json index 696efa6..0e4cc7f 100644 --- a/modules/assets/src/common/locales/en.json +++ b/modules/assets/src/common/locales/en.json @@ -51,11 +51,6 @@ "choose": "Choose a tool", "chooseState": "Choose a status", "newTool": "New tool", - "tools": "Assistance tools", - "editTool": "Edit tool", - "toolName": "Name", - "active": "Active", - "builtin": "Built-in", "destination": "Identifier or destination (optional)", "requiresVpn": "Requires VPN", "preferred": "Preferred method", @@ -75,22 +70,15 @@ "examples": "AnyDesk: 123 456 789; Supremo Control: its identifier; Remote Desktop: PORT-ANA-01, with VPN if required.", "examplesLabel": "Examples and guidance", "scope": "Do not enter passwords or access keys in the identifier or notes.", - "toolHelp": "What is a tool for?", - "toolDescription": "Global assistance tool catalog shared across companies.", - "toolGuidance": "Add tools you use. Deactivation prevents selection in new methods and preserves existing records. Built-in names are protected.", - "toolExamples": "AnyDesk, Supremo Control, Remote Desktop, TeamViewer or RustDesk.", "review": "Review: the asset is retired or the tool is inactive.", "errors": { "INVALID_REQUEST": "Check the fields. Only an available method can be preferred.", "ASSET_NOT_FOUND": "The asset does not belong to this company or is unavailable.", "ASSET_RETIRED": "Only retired methods can be saved for a retired asset.", - "TOOL_UNAVAILABLE": "Select an active tool.", + "TOOL_UNAVAILABLE": "Select an active assistance resource from this company.", "CONNECTION_NOT_FOUND": "This method is unavailable.", "PREFERRED_EXISTS": "A preferred method already exists. Uncheck it before choosing another.", "CONNECTION_EXISTS": "This method already exists with the same identifier or destination.", - "TOOL_NOT_FOUND": "This tool is unavailable.", - "TOOL_EXISTS": "A tool with this name already exists.", - "BUILTIN_PROTECTED": "Built-in tool names cannot be changed.", "INTERNAL_ERROR": "The operation could not be completed." } }, diff --git a/modules/assets/src/common/locales/es.json b/modules/assets/src/common/locales/es.json index f256294..870ff11 100644 --- a/modules/assets/src/common/locales/es.json +++ b/modules/assets/src/common/locales/es.json @@ -51,11 +51,6 @@ "choose": "Selecciona una herramienta", "chooseState": "Selecciona un estado", "newTool": "Nueva herramienta", - "tools": "Herramientas de asistencia", - "editTool": "Editar herramienta", - "toolName": "Nombre", - "active": "Activa", - "builtin": "De serie", "destination": "Identificador o destino (opcional)", "requiresVpn": "Requiere VPN", "preferred": "Método preferido", @@ -75,22 +70,15 @@ "examples": "AnyDesk: 123 456 789; Supremo Control: su identificador; Escritorio remoto: PORT-ANA-01, con VPN si es necesaria.", "examplesLabel": "Ejemplos y recomendaciones", "scope": "No introduzcas contraseñas ni claves de acceso en el identificador o las notas.", - "toolHelp": "¿Para qué sirve una herramienta?", - "toolDescription": "Catálogo global de herramientas de asistencia, común a todas las compañías.", - "toolGuidance": "Añade las herramientas que utilicéis. Desactivar impide seleccionarlas en nuevos métodos y conserva los existentes. Los nombres de serie están protegidos.", - "toolExamples": "AnyDesk, Supremo Control, Escritorio remoto, TeamViewer o RustDesk.", "review": "Revisar: el activo está retirado o la herramienta está desactivada.", "errors": { "INVALID_REQUEST": "Revisa los campos. Solo un método disponible puede marcarse como preferido.", "ASSET_NOT_FOUND": "El activo no pertenece a esta compañía o ya no está disponible.", "ASSET_RETIRED": "En un activo retirado solo se pueden guardar métodos retirados.", - "TOOL_UNAVAILABLE": "Selecciona una herramienta activa.", + "TOOL_UNAVAILABLE": "Selecciona un recurso de asistencia activo de esta compañía.", "CONNECTION_NOT_FOUND": "El método ya no está disponible.", "PREFERRED_EXISTS": "Ya hay un método preferido. Desmárcalo antes de elegir otro.", "CONNECTION_EXISTS": "Ya existe este método con el mismo identificador o destino.", - "TOOL_NOT_FOUND": "La herramienta ya no está disponible.", - "TOOL_EXISTS": "Ya existe una herramienta con ese nombre.", - "BUILTIN_PROTECTED": "El nombre de una herramienta de serie no se puede modificar.", "INTERNAL_ERROR": "No se pudo completar la operación." } }, diff --git a/modules/assets/src/common/support.ts b/modules/assets/src/common/support.ts index 4924fa1..9e29848 100644 --- a/modules/assets/src/common/support.ts +++ b/modules/assets/src/common/support.ts @@ -1,16 +1,15 @@ import { z } from "zod/v4"; -import { SupportToolInputSchema, SupportValuesSchema } from "../api/domain/support-values"; +import { SupportValuesSchema } from "../api/domain/support-values"; -export type { SupportInput, SupportToolInput } from "../api/domain/support-values"; +export type { SupportInput } from "../api/domain/support-values"; -export { SupportToolInputSchema, SupportValuesSchema }; +export { SupportValuesSchema }; export const SupportToolSchema = z.strictObject({ id: z.uuid(), name: z.string(), active: z.boolean(), - builtin: z.boolean(), }); export const SupportConnectionSchema = z.strictObject({ @@ -36,10 +35,6 @@ export const SupportListSchema = z.strictObject({ export const SupportResponseSchema = z.strictObject({ connection: SupportConnectionSchema }); -export const SupportToolsSchema = z.strictObject({ tools: z.array(SupportToolSchema) }); - -export const SupportToolResponseSchema = z.strictObject({ tool: SupportToolSchema }); - export type SupportConnectionDTO = z.infer; export type SupportToolDTO = z.infer; diff --git a/modules/assets/src/web/shared/queries/support-query-keys.ts b/modules/assets/src/web/shared/queries/support-query-keys.ts index 1fe3025..e359241 100644 --- a/modules/assets/src/web/shared/queries/support-query-keys.ts +++ b/modules/assets/src/web/shared/queries/support-query-keys.ts @@ -1,4 +1,3 @@ export const supportKeys = { asset: (companyId: string, assetId: string) => ["asset-support", companyId, assetId], - tools: ["support-tools"], }; diff --git a/modules/assets/src/web/support-connections/detail/ui/support-summary.tsx b/modules/assets/src/web/support-connections/detail/ui/support-summary.tsx index dc66233..75d411d 100644 --- a/modules/assets/src/web/support-connections/detail/ui/support-summary.tsx +++ b/modules/assets/src/web/support-connections/detail/ui/support-summary.tsx @@ -14,7 +14,7 @@ export function SupportSummary({ return (

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

diff --git a/modules/assets/src/web/support-connections/list/controllers/use-asset-support-controller.ts b/modules/assets/src/web/support-connections/list/controllers/use-asset-support-controller.ts index 06c5170..abeea27 100644 --- a/modules/assets/src/web/support-connections/list/controllers/use-asset-support-controller.ts +++ b/modules/assets/src/web/support-connections/list/controllers/use-asset-support-controller.ts @@ -13,7 +13,6 @@ export function useAssetSupportController({ companyId, assetId }: AssetSupportPr }); const [open, setOpen] = useState(false); const [edit, setEdit] = useState(); - const [toolsOpen, setToolsOpen] = useState(false); const [showRetired, setShowRetired] = useState(false); const [historyId, setHistoryId] = useState(); const history = query.data?.connections.find((connection) => connection.id === historyId); @@ -27,8 +26,6 @@ export function useAssetSupportController({ companyId, assetId }: AssetSupportPr setOpen, edit, setEdit, - toolsOpen, - setToolsOpen, showRetired, setShowRetired, setHistoryId, diff --git a/modules/assets/src/web/support-connections/list/ui/asset-support.tsx b/modules/assets/src/web/support-connections/list/ui/asset-support.tsx index cfd8546..e1a58c4 100644 --- a/modules/assets/src/web/support-connections/list/ui/asset-support.tsx +++ b/modules/assets/src/web/support-connections/list/ui/asset-support.tsx @@ -3,7 +3,6 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { DeleteRecordDialog } from "../../../shared/ui/delete-record-dialog"; -import { SupportToolsDialog } from "../../../support-tools"; import { SupportSummary } from "../../detail"; import { SupportHistoryDialog } from "../../history"; import { SupportConnectionEditorDialog } from "../../shared"; @@ -18,8 +17,6 @@ export function AssetSupport(props: AssetSupportProps) { setOpen, edit, setEdit, - toolsOpen, - setToolsOpen, showRetired, setShowRetired, setHistoryId, @@ -35,9 +32,6 @@ export function AssetSupport(props: AssetSupportProps) {
{!compact &&

{t("support.title")}

}
- - { - form.setValue("toolId", id); + form.setValue("resourceId", id); setQuick(false); }} open={quick} diff --git a/modules/assets/src/web/support-connections/shared/ui/support-help.tsx b/modules/assets/src/web/support-connections/shared/ui/support-help.tsx index da427c6..5b58517 100644 --- a/modules/assets/src/web/support-connections/shared/ui/support-help.tsx +++ b/modules/assets/src/web/support-connections/shared/ui/support-help.tsx @@ -1,17 +1,17 @@ import { ContextHelp } from "@repo/rdx-ui/components/context-help"; import { useTranslation } from "react-i18next"; -export function SupportHelp({ tool = false }: { tool?: boolean }) { +export function SupportHelp() { const { t } = useTranslation("assets"); return ( ); } diff --git a/modules/assets/src/web/support-tools/shared/ui/support-tool-editor-dialog.tsx b/modules/assets/src/web/support-connections/shared/ui/support-resource-create-dialog.tsx similarity index 51% rename from modules/assets/src/web/support-tools/shared/ui/support-tool-editor-dialog.tsx rename to modules/assets/src/web/support-connections/shared/ui/support-resource-create-dialog.tsx index 5e13e87..1cdce37 100644 --- a/modules/assets/src/web/support-tools/shared/ui/support-tool-editor-dialog.tsx +++ b/modules/assets/src/web/support-connections/shared/ui/support-resource-create-dialog.tsx @@ -1,19 +1,17 @@ +import { ResourceEditor } from "@metis/resources/client"; import { FormDialog } from "@repo/rdx-ui/components/form-dialog"; -import type { ComponentProps } from "react"; import { useTranslation } from "react-i18next"; -import { SupportToolForm } from "./support-tool-form"; - -export function SupportToolEditorDialog({ +export function SupportResourceCreateDialog({ + companyId, open, - edit, - onSaved, onOpenChange, + onSaved, }: { + companyId: string; open: boolean; - edit: ComponentProps["tool"]; - onSaved: ComponentProps["onSaved"]; onOpenChange: (open: boolean) => void; + onSaved: (id: string) => void; }) { const { t } = useTranslation("assets"); @@ -23,11 +21,13 @@ export function SupportToolEditorDialog({ formActions onOpenChange={onOpenChange} open={open} - title={t(edit ? "support.editTool" : "support.newTool")} + title={t("support.newTool")} + wide > - diff --git a/modules/assets/src/web/support-tools/index.ts b/modules/assets/src/web/support-tools/index.ts deleted file mode 100644 index b256b28..0000000 --- a/modules/assets/src/web/support-tools/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { SupportToolsDialog } from "./list"; - -export { SupportToolEditorDialog, SupportToolForm } from "./shared"; diff --git a/modules/assets/src/web/support-tools/list/api/index.ts b/modules/assets/src/web/support-tools/list/api/index.ts deleted file mode 100644 index 3ac32b6..0000000 --- a/modules/assets/src/web/support-tools/list/api/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { listSupportTools } from "./list-support-tools.api"; diff --git a/modules/assets/src/web/support-tools/list/api/list-support-tools.api.ts b/modules/assets/src/web/support-tools/list/api/list-support-tools.api.ts deleted file mode 100644 index 2d906f9..0000000 --- a/modules/assets/src/web/support-tools/list/api/list-support-tools.api.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { SupportToolsSchema } from "../../../../common/support"; -import { supportRequest } from "../../../shared/api/support-request"; - -export async function listSupportTools() { - return SupportToolsSchema.parse(await supportRequest("tools")); -} diff --git a/modules/assets/src/web/support-tools/list/controllers/index.ts b/modules/assets/src/web/support-tools/list/controllers/index.ts deleted file mode 100644 index 18ca562..0000000 --- a/modules/assets/src/web/support-tools/list/controllers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useSupportToolsController } from "./use-support-tools-controller"; diff --git a/modules/assets/src/web/support-tools/list/controllers/use-support-tools-controller.ts b/modules/assets/src/web/support-tools/list/controllers/use-support-tools-controller.ts deleted file mode 100644 index e91d2ef..0000000 --- a/modules/assets/src/web/support-tools/list/controllers/use-support-tools-controller.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; - -import type { SupportToolDTO } from "../../../../common/support"; -import { supportKeys } from "../../../shared/queries"; -import { listSupportTools } from "../api"; - -export function useSupportToolsController() { - const query = useQuery({ queryKey: supportKeys.tools, queryFn: () => listSupportTools() }); - const [edit, setEdit] = useState(); - const [open, setOpen] = useState(false); - - return { query, edit, setEdit, open, setOpen }; -} diff --git a/modules/assets/src/web/support-tools/list/index.ts b/modules/assets/src/web/support-tools/list/index.ts deleted file mode 100644 index c9811fc..0000000 --- a/modules/assets/src/web/support-tools/list/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { SupportToolsDialog } from "./ui"; diff --git a/modules/assets/src/web/support-tools/list/ui/index.ts b/modules/assets/src/web/support-tools/list/ui/index.ts deleted file mode 100644 index 122989b..0000000 --- a/modules/assets/src/web/support-tools/list/ui/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { SupportToolsDialog } from "./support-tools-dialog"; diff --git a/modules/assets/src/web/support-tools/list/ui/support-tools-dialog.tsx b/modules/assets/src/web/support-tools/list/ui/support-tools-dialog.tsx deleted file mode 100644 index b950229..0000000 --- a/modules/assets/src/web/support-tools/list/ui/support-tools-dialog.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { FormDialog } from "@repo/rdx-ui/components/form-dialog"; -import { useTranslation } from "react-i18next"; - -import { SupportTools } from "./support-tools"; - -export function SupportToolsDialog({ - open, - onOpenChange, -}: { - open: boolean; - onOpenChange: (open: boolean) => void; -}) { - const { t } = useTranslation("assets"); - - return ( - - - - ); -} diff --git a/modules/assets/src/web/support-tools/list/ui/support-tools.tsx b/modules/assets/src/web/support-tools/list/ui/support-tools.tsx deleted file mode 100644 index c79c7fe..0000000 --- a/modules/assets/src/web/support-tools/list/ui/support-tools.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { - EntityAction, - EntityListHeader, - EntityRowActions, - EntityStatus, - EntityTable, -} from "@repo/rdx-ui/components/entity-list"; -import { Button } from "@repo/shadcn-ui/components/button"; -import { useTranslation } from "react-i18next"; - -import { SupportToolEditorDialog } from "../../shared"; -import { useSupportToolsController } from "../controllers"; - -export function SupportTools() { - const { query, edit, setEdit, open, setOpen } = useSupportToolsController(); - const { t, i18n } = useTranslation("assets"); - - return ( -
- - {" "} - {" "} - - } - locale={i18n.language} - title={t("support.tools")} - /> - {query.isPending &&

{t("support.loading")}

} - {query.isError && ( -
-

{t("support.loadError")}

- -
- )} - tool.name, - hideable: false, - render: (tool) => {tool.name}, - }, - { - id: "state", - label: t("status"), - value: (tool) => tool.active, - render: (tool) => ( - - {t(tool.active ? "support.active" : "inactive")} - - ), - }, - { - id: "origin", - label: t("origin"), - value: (tool) => tool.builtin, - render: (tool) => <>{t(tool.builtin ? "support.builtin" : "custom")}, - }, - { - id: "actions", - label: t("actions"), - hideable: false, - render: (tool) => ( - - { - setEdit(tool); - setOpen(true); - }} - > - {t("edit")} - - - ), - }, - ]} - items={query.data?.tools ?? []} - loading={query.isPending} - locale={i18n.language} - title={t("support.tools")} - unavailable={query.isError} - /> - setOpen(false)} - open={open} - /> -
- ); -} diff --git a/modules/assets/src/web/support-tools/shared/api/index.ts b/modules/assets/src/web/support-tools/shared/api/index.ts deleted file mode 100644 index eea1a72..0000000 --- a/modules/assets/src/web/support-tools/shared/api/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { saveSupportTool } from "./save-support-tool.api"; diff --git a/modules/assets/src/web/support-tools/shared/api/save-support-tool.api.ts b/modules/assets/src/web/support-tools/shared/api/save-support-tool.api.ts deleted file mode 100644 index 59005ec..0000000 --- a/modules/assets/src/web/support-tools/shared/api/save-support-tool.api.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { SupportToolInput } from "../../../../common/support"; -import { SupportToolResponseSchema } from "../../../../common/support"; -import { supportRequest } from "../../../shared/api/support-request"; - -export async function saveSupportTool(values: SupportToolInput, id?: string) { - return SupportToolResponseSchema.parse( - await supportRequest(`tools${id ? `/${id}/update` : ""}`, values) - ); -} diff --git a/modules/assets/src/web/support-tools/shared/controllers/index.ts b/modules/assets/src/web/support-tools/shared/controllers/index.ts deleted file mode 100644 index c6350c4..0000000 --- a/modules/assets/src/web/support-tools/shared/controllers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useSupportToolFormController } from "./use-support-tool-form-controller"; diff --git a/modules/assets/src/web/support-tools/shared/controllers/use-support-tool-form-controller.ts b/modules/assets/src/web/support-tools/shared/controllers/use-support-tool-form-controller.ts deleted file mode 100644 index c4c372a..0000000 --- a/modules/assets/src/web/support-tools/shared/controllers/use-support-tool-form-controller.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useForm } from "react-hook-form"; - -import type { SupportToolInput } from "../../../../common/support"; -import { SupportToolInputSchema } from "../../../../common/support"; -import { supportKeys } from "../../../shared/queries"; -import { saveSupportTool } from "../api"; -import type { SupportToolFormProps } from "../types"; - -export function useSupportToolFormController({ tool, onSaved }: SupportToolFormProps) { - const client = useQueryClient(); - const form = useForm({ - resolver: zodResolver(SupportToolInputSchema), - defaultValues: tool ? { name: tool.name, active: tool.active } : { name: "", active: true }, - }); - const mutation = useMutation({ - mutationFn: (values: SupportToolInput) => saveSupportTool(values, tool?.id), - onSuccess: async (response) => { - await Promise.all([ - client.invalidateQueries({ queryKey: supportKeys.tools }), - client.invalidateQueries({ queryKey: ["asset-support"] }), - ]); - onSaved(response.tool.id); - }, - }); - const submit = form.handleSubmit((values) => mutation.mutate(values)); - - return { form, mutation, submit }; -} diff --git a/modules/assets/src/web/support-tools/shared/index.ts b/modules/assets/src/web/support-tools/shared/index.ts deleted file mode 100644 index a9eda4b..0000000 --- a/modules/assets/src/web/support-tools/shared/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { SupportToolEditorDialog, SupportToolForm } from "./ui"; diff --git a/modules/assets/src/web/support-tools/shared/types/index.ts b/modules/assets/src/web/support-tools/shared/types/index.ts deleted file mode 100644 index 7e076ef..0000000 --- a/modules/assets/src/web/support-tools/shared/types/index.ts +++ /dev/null @@ -1 +0,0 @@ -export type { SupportToolFormProps } from "./support-tool-form-props"; diff --git a/modules/assets/src/web/support-tools/shared/types/support-tool-form-props.ts b/modules/assets/src/web/support-tools/shared/types/support-tool-form-props.ts deleted file mode 100644 index ceb65a4..0000000 --- a/modules/assets/src/web/support-tools/shared/types/support-tool-form-props.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { SupportToolDTO } from "../../../../common/support"; - -export type SupportToolFormProps = { - tool?: SupportToolDTO; - onSaved: (id: string) => void; -}; diff --git a/modules/assets/src/web/support-tools/shared/ui/index.ts b/modules/assets/src/web/support-tools/shared/ui/index.ts deleted file mode 100644 index ba78f00..0000000 --- a/modules/assets/src/web/support-tools/shared/ui/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { SupportToolEditorDialog } from "./support-tool-editor-dialog"; - -export { SupportToolForm } from "./support-tool-form"; diff --git a/modules/assets/src/web/support-tools/shared/ui/support-tool-form.tsx b/modules/assets/src/web/support-tools/shared/ui/support-tool-form.tsx deleted file mode 100644 index 85fe103..0000000 --- a/modules/assets/src/web/support-tools/shared/ui/support-tool-form.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog"; -import { SwitchField } from "@repo/rdx-ui/components/switch-field"; -import { Button } from "@repo/shadcn-ui/components/button"; -import { Input } from "@repo/shadcn-ui/components/input"; -import { Label } from "@repo/shadcn-ui/components/label"; -import { useId } from "react"; -import { useTranslation } from "react-i18next"; - -import { SupportFeedback } from "../../../shared"; -import { SupportHelp } from "../../../support-connections/shared/ui/support-help"; -import { useSupportToolFormController } from "../controllers"; -import type { SupportToolFormProps } from "../types"; - -export function SupportToolForm(props: SupportToolFormProps) { - const { tool } = props; - const { form, mutation, submit } = useSupportToolFormController(props); - const { t } = useTranslation("assets"); - const id = useId(); - - return ( -
- -
-
- - -
- form.setValue("active", active, { shouldDirty: true })} - /> -
- 0} - /> - -
- - - -
- ); -} diff --git a/modules/companies/src/api/application/application-configuration.repository.ts b/modules/companies/src/api/application/application-configuration.repository.ts index 8fb0ef0..58dc7d3 100644 --- a/modules/companies/src/api/application/application-configuration.repository.ts +++ b/modules/companies/src/api/application/application-configuration.repository.ts @@ -13,7 +13,7 @@ export type ApplicationContext = Readonly<{ resources: readonly { id: UniqueID; active: boolean; - usage: "application" | "certificate" | null; + usage: "application" | "certificate" | "support" | null; }[]; actors: ReadonlyMap; }>; diff --git a/modules/companies/src/api/application/certificate-configuration.repository.ts b/modules/companies/src/api/application/certificate-configuration.repository.ts index 7ff170b..1774f67 100644 --- a/modules/companies/src/api/application/certificate-configuration.repository.ts +++ b/modules/companies/src/api/application/certificate-configuration.repository.ts @@ -14,7 +14,7 @@ export type CertificateContext = Readonly<{ resources: readonly { id: UniqueID; active: boolean; - usage: "application" | "certificate" | null; + usage: "application" | "certificate" | "support" | null; expiresOn: string | null; }[]; actors: ReadonlyMap; diff --git a/modules/resources/src/api/application/resource-inventory.service.ts b/modules/resources/src/api/application/resource-inventory.service.ts index 5ee5621..11d202d 100644 --- a/modules/resources/src/api/application/resource-inventory.service.ts +++ b/modules/resources/src/api/application/resource-inventory.service.ts @@ -1,5 +1,7 @@ import type { UniqueID } from "@repo/rdx-ddd"; +import type { ResourceDefinitionSpec } from "../domain/resource-definition"; + import type { IResourceRepository } from "./resource.repository"; export interface IResourceInventoryService { @@ -8,7 +10,7 @@ export interface IResourceInventoryService { id: UniqueID; name: string; active: boolean; - usage: "application" | "certificate" | null; + usage: "application" | "certificate" | "support" | null; expiresOn: string | null; }[] >; @@ -33,14 +35,7 @@ export class ResourceInventoryService implements IResourceInventoryService { ); const data = template?.values.data; const key = template?.stockKey.getOrUndefined(); - const usage: "application" | "certificate" | null = - key === "template:application" - ? "application" - : key === "template:certificate" - ? "certificate" - : data?.kind === "template" - ? (data.usage ?? null) - : null; + const usage = resourceUsage(key, data); const expiresOn = expiryFieldId ? resource.values.toPrimitive().characteristics[expiryFieldId] : null; @@ -55,3 +50,16 @@ export class ResourceInventoryService implements IResourceInventoryService { }); } } + +function resourceUsage(key: string | undefined, definition: ResourceDefinitionSpec | undefined) { + switch (key) { + case "template:application": + return "application"; + case "template:certificate": + return "certificate"; + case "template:support": + return "support"; + default: + return definition?.kind === "template" ? (definition.usage ?? null) : null; + } +} diff --git a/modules/resources/src/api/domain/resource-catalog-seed.ts b/modules/resources/src/api/domain/resource-catalog-seed.ts index 5e46bd8..b2056ac 100644 --- a/modules/resources/src/api/domain/resource-catalog-seed.ts +++ b/modules/resources/src/api/domain/resource-catalog-seed.ts @@ -35,6 +35,7 @@ const TEMPLATES: readonly [string, string, readonly string[]][] = [ ["mailbox", "Buzón de correo", ["email", "provider", "context"]], ["access", "Recurso de acceso", ["reference"]], ["shared_folder", "Carpeta compartida", ["unc", "server", "description"]], + ["support", "Herramienta de asistencia remota", ["provider", "url", "description"]], ["application", "Aplicación", ["provider", "url", "description"]], ["certificate", "Certificado digital", ["holder", "issuer", "expires", "fingerprint", "purpose"]], [ @@ -116,6 +117,7 @@ export function seedResourceCatalog(catalog: ResourceCatalog) { add(`template:${key}`, { kind: "template", + ...(key === "support" ? { usage: "support" as const } : {}), name, description: "", fields: references, diff --git a/modules/resources/src/api/domain/resource-catalog.test.ts b/modules/resources/src/api/domain/resource-catalog.test.ts index 9dae31c..c60de8b 100644 --- a/modules/resources/src/api/domain/resource-catalog.test.ts +++ b/modules/resources/src/api/domain/resource-catalog.test.ts @@ -29,7 +29,7 @@ const field = { test("catálogo de recursos: serie idempotente, UUID v7, características opcionales y protección", () => { const catalog = seedResourceCatalog(ResourceCatalog.create()); - assert.equal(catalog.definitions.length, 26); + assert.equal(catalog.definitions.length, 27); assert.equal(seedResourceCatalog(catalog), catalog); assert.ok(catalog.definitions.every((item) => item.id.toPrimitive()[14] === "7")); const template = catalog.definitions.find((item) => item.values.data.kind === "template"); @@ -170,7 +170,7 @@ test("licencias: ampliar un catálogo previo conserva identidades y disponibilid assert.ok(disabled.isSuccess); const upgraded = seedResourceCatalog(disabled.data); - assert.equal(upgraded.definitions.length, 26); + assert.equal(upgraded.definitions.length, 27); for (const existing of disabled.data.definitions) { assert.equal( upgraded.definitions.find((item) => item.id.equals(existing.id)), diff --git a/modules/resources/src/api/domain/resource-definition.ts b/modules/resources/src/api/domain/resource-definition.ts index 5db1f41..32763f7 100644 --- a/modules/resources/src/api/domain/resource-definition.ts +++ b/modules/resources/src/api/domain/resource-definition.ts @@ -34,7 +34,7 @@ export const ResourceDefinitionSchema = z.discriminatedUnion("kind", [ description: z.string().max(1000), fields: z.array(TemplateFieldSchema).min(1).max(100), sections: z.array(TemplateSectionSchema).max(30).optional(), - usage: z.enum(["application", "certificate"]).optional(), + usage: z.enum(["application", "certificate", "support"]).optional(), }) .refine((template) => validTemplateSections(template.sections ?? [], template.fields), { path: ["sections"], diff --git a/modules/resources/src/api/infrastructure/persistence/migrate-support-resources.ts b/modules/resources/src/api/infrastructure/persistence/migrate-support-resources.ts new file mode 100644 index 0000000..91b8543 --- /dev/null +++ b/modules/resources/src/api/infrastructure/persistence/migrate-support-resources.ts @@ -0,0 +1,222 @@ +import type { Sequelize, Transaction } from "sequelize"; +import { DataTypes, QueryTypes } from "sequelize"; +import { z } from "zod/v4"; + +import { Resource } from "../../domain/resource.aggregate"; +import { + ResourceSpecification, + ResourceValues, +} from "../../domain/value-objects/resource-values.vo"; + +import { writeInventory } from "./resource-inventory-relational"; +import { ResourcePersistenceMapper, inventoryIdentity } from "./resource-persistence.mapper"; + +const mappingTable = "support_tool_resource_migrations"; + +async function specification(database: Sequelize, transaction: Transaction) { + const templates = await database.query<{ id: string; name: string }>( + "SELECT id,name FROM resource_definitions WHERE stockKey='template:support'", + { type: QueryTypes.SELECT, transaction } + ); + const template = templates[0]; + + if (!template) { + throw new Error("Plantilla de asistencia ausente."); + } + const fields = await database.query<{ fieldId: string; name: string; unit: string }>( + "SELECT f.id AS fieldId,f.name,f.unit FROM resource_template_fields b JOIN resource_definitions f ON f.id=b.fieldId WHERE b.templateId=:id ORDER BY b.position", + { replacements: { id: template.id }, type: QueryTypes.SELECT, transaction } + ); + const result = ResourceSpecification.create({ + templateId: template.id, + templateName: template.name, + fields: fields.map((field) => ({ ...field, type: "text", choices: [], required: false })), + }); + + if (!result.isSuccess) { + throw new Error("Especificación de asistencia inválida."); + } + + return result.data; +} + +/** Traslada el catálogo a recursos por compañía y conserva las conexiones y sus revisiones. */ +export async function migrateSupportResources(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 } + ); + + if (!tables.some((table) => table.name === "support_tools")) { + if (tables.some((table) => table.name === "support_catalog_lock")) { + await query.dropTable("support_catalog_lock"); + } + + return; + } + if (!tables.some((table) => table.name === mappingTable)) { + await query.createTable(mappingTable, { + toolId: { type: DataTypes.UUID, primaryKey: true }, + companyId: { type: DataTypes.UUID, primaryKey: true }, + resourceId: { + type: DataTypes.UUID, + allowNull: false, + unique: true, + references: { model: "resources", key: "id" }, + onDelete: "RESTRICT", + }, + }); + } + await database.transaction((transaction) => migrateTools(database, transaction)); + for (const table of ["asset_support", "asset_support_revisions"] as const) { + if (!tables.some((item) => item.name === table)) { + continue; + } + await migrateConnectionReferences(database, table); + } + await query.dropTable(mappingTable); + await query.dropTable("support_tools"); + if (tables.some((table) => table.name === "support_catalog_lock")) { + await query.dropTable("support_catalog_lock"); + } +} + +async function migrateConnectionReferences( + database: Sequelize, + table: "asset_support" | "asset_support_revisions" +) { + const query = database.getQueryInterface(); + const columns = await query.describeTable(table); + + if (!columns.toolId) { + return; + } + if (!columns.resourceId) { + await query.addColumn(table, "resourceId", { type: DataTypes.UUID, allowNull: true }); + } + await database.query( + table === "asset_support" + ? "UPDATE asset_support s JOIN support_tool_resource_migrations m ON m.companyId=s.companyId AND m.toolId=s.toolId SET s.resourceId=m.resourceId" + : "UPDATE asset_support_revisions h JOIN asset_support s ON s.id=h.connectionId JOIN support_tool_resource_migrations m ON m.companyId=s.companyId AND m.toolId=h.toolId SET h.resourceId=m.resourceId" + ); + const missing = await database.query( + `SELECT id FROM \`${table}\` WHERE resourceId IS NULL LIMIT 1`, + { type: QueryTypes.SELECT } + ); + + if (missing.length) { + throw new Error("Conexión de asistencia sin recurso migrado."); + } + await query.changeColumn(table, "resourceId", { type: DataTypes.UUID, allowNull: false }); + const constraints = await database.query<{ name: string }>( + "SELECT CONSTRAINT_NAME AS name FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=:table AND COLUMN_NAME='toolId' AND REFERENCED_TABLE_NAME IS NOT NULL", + { replacements: { table }, type: QueryTypes.SELECT } + ); + + for (const constraint of constraints) { + await query.removeConstraint(table, constraint.name); + } + await database.query(`ALTER TABLE ${table} DROP COLUMN toolId`); +} + +const ToolSchema = z.object({ + id: z.uuid(), + name: z.string(), + active: z.union([z.boolean(), z.literal(0), z.literal(1)]).transform(Boolean), +}); + +async function migrateTools(database: Sequelize, transaction: Transaction) { + const companies = await database.query<{ id: string }>( + "SELECT id FROM companies ORDER BY id FOR UPDATE", + { type: QueryTypes.SELECT, transaction } + ); + + if (!companies.length) { + return; + } + const actors = await database.query<{ id: string }>( + "SELECT id FROM users ORDER BY isInitialAdmin DESC,id LIMIT 1", + { type: QueryTypes.SELECT, transaction } + ); + const actor = actors[0]; + + if (!actor) { + throw new Error("La migración necesita un usuario para la auditoría."); + } + const rows = await database.query>( + "SELECT id,name,active FROM support_tools ORDER BY id FOR UPDATE", + { type: QueryTypes.SELECT, transaction } + ); + const tools = rows.map((row) => ToolSchema.parse(row)); + const spec = await specification(database, transaction); + + for (const company of companies) { + for (const tool of tools) { + await migrateTool(database, company.id, tool, spec, actor.id, transaction); + } + } +} + +async function migrateTool( + database: Sequelize, + companyId: string, + tool: z.infer, + spec: ResourceSpecification, + actorId: string, + transaction: Transaction +) { + const replacements = { toolId: tool.id, companyId }; + const existing = await database.query( + "SELECT resourceId FROM support_tool_resource_migrations WHERE toolId=:toolId AND companyId=:companyId", + { replacements, type: QueryTypes.SELECT, transaction } + ); + + if (existing.length) { + return; + } + const values = ResourceValues.create( + { + templateId: spec.templateId.toPrimitive(), + name: tool.name, + active: tool.active, + notes: "", + characteristics: {}, + }, + spec + ); + + if (!values.isSuccess) { + throw new Error("Herramienta de asistencia inválida."); + } + const resource = Resource.create( + inventoryIdentity(companyId), + spec, + values.data, + inventoryIdentity(actorId), + new Date().toISOString() + ); + + if (!resource.isSuccess) { + throw new Error("No se pudo crear el recurso de asistencia."); + } + const rows = new ResourcePersistenceMapper().toPersistence(resource.data); + // Conserva el UUID de la herramienta en la primera compañía que pueda utilizarlo. + const occupied = await database.query("SELECT id FROM resources WHERE id=:id", { + replacements: { id: tool.id }, + type: QueryTypes.SELECT, + transaction, + }); + + if (!occupied.length) { + rows.resource.id = tool.id; + for (const revision of rows.revisions) { + revision.resourceId = tool.id; + } + } + await writeInventory(database, rows, new Set(), transaction); + await database.query( + "INSERT INTO support_tool_resource_migrations (toolId,companyId,resourceId) VALUES (:toolId,:companyId,:resourceId)", + { replacements: { ...replacements, resourceId: rows.resource.id }, transaction } + ); +} diff --git a/modules/resources/src/api/module.ts b/modules/resources/src/api/module.ts index c01b950..bd74aac 100644 --- a/modules/resources/src/api/module.ts +++ b/modules/resources/src/api/module.ts @@ -10,6 +10,7 @@ import { seedResourceCatalog } from "./domain/resource-catalog-seed"; import { registerResourceRoutes } from "./infrastructure/http/resource.routes"; import { registerResourceCatalogRoutes } from "./infrastructure/http/resource-catalog.routes"; import { migrateLegacyResources } from "./infrastructure/persistence/migrate-legacy-resources"; +import { migrateSupportResources } from "./infrastructure/persistence/migrate-support-resources"; import { migrateInventory } from "./infrastructure/persistence/resource.model"; import { ResourceCatalogModel, @@ -40,6 +41,7 @@ export async function createResourceModule(database: Sequelize, authenticate: Re } await migrateInventory(database); await enforceResourceConstraints(database); + await migrateSupportResources(database); const catalog = new ResourceCatalogUseCases(repository); const inventoryRepository = new SequelizeResourceRepository(database); const inventory = new ResourceUseCases(inventoryRepository); diff --git a/modules/resources/src/common/locales/en.json b/modules/resources/src/common/locales/en.json index e343b00..91dee7e 100644 --- a/modules/resources/src/common/locales/en.json +++ b/modules/resources/src/common/locales/en.json @@ -21,7 +21,7 @@ "history": "History", "recordedBy": "Recorded by {{name}}", "snapshotNotice": "This record retains the template fields captured when the resource was created.", - "resourceDescription": "Register a mailbox, shared folder, application, certificate or software license used in this company's context.", + "resourceDescription": "Register a mailbox, shared folder, application, certificate, software license or assistance tool for this company.", "resourceExamples": "Examples: admin@company.com, Administration folder, corporate Zoom, signing certificate or Microsoft 365 subscription.", "resourceGuidance": "Characteristics are optional unless the template requires them. Never store passwords or private keys.", "resourceScope": "This record identifies the company resource. Availability and device configuration are managed separately.", @@ -58,11 +58,12 @@ "editField": "Edit field", "newTemplate": "New template", "templateUsage": "Template use", - "templateUsageHelp": "Enables resources in user profiles. The use is fixed after creation.", + "templateUsageHelp": "Allows these resources to be used as profile applications or certificates, or as remote assistance tools. Usage is fixed after creation.", "usage": { "general": "General", "application": "Application", - "certificate": "Digital certificate" + "certificate": "Digital certificate", + "support": "Remote assistance" }, "editTemplate": "Edit template", "name": "Name", diff --git a/modules/resources/src/common/locales/es.json b/modules/resources/src/common/locales/es.json index 253d30e..50553f1 100644 --- a/modules/resources/src/common/locales/es.json +++ b/modules/resources/src/common/locales/es.json @@ -21,7 +21,7 @@ "history": "Historial", "recordedBy": "Registrado por {{name}}", "snapshotNotice": "La ficha conserva los campos de la plantilla utilizados al dar de alta el recurso.", - "resourceDescription": "Registra un buzón, carpeta compartida, aplicación, certificado o licencia de software disponible en el contexto de esta compañía.", + "resourceDescription": "Registra un buzón, carpeta compartida, aplicación, certificado, licencia de software o herramienta de asistencia de esta compañía.", "resourceExamples": "Ejemplos: administracion@empresa.es, carpeta Administración, Zoom corporativo, certificado de firma o suscripción Microsoft 365.", "resourceGuidance": "Las características son opcionales salvo que la plantilla indique lo contrario. No registres contraseñas ni claves privadas.", "resourceScope": "Este registro identifica el recurso de la compañía. Su disponibilidad y su configuración en los equipos se gestionan por separado.", @@ -58,11 +58,12 @@ "editField": "Editar campo", "newTemplate": "Nueva plantilla", "templateUsage": "Uso de la plantilla", - "templateUsageHelp": "Permite utilizar sus recursos en perfiles de usuario. El uso queda fijo tras el alta.", + "templateUsageHelp": "Permite usar estos recursos como aplicaciones o certificados de perfiles, o como herramientas de asistencia remota. El uso queda fijo tras el alta.", "usage": { "general": "General", "application": "Aplicación", - "certificate": "Certificado digital" + "certificate": "Certificado digital", + "support": "Asistencia remota" }, "editTemplate": "Editar plantilla", "name": "Nombre", diff --git a/modules/resources/src/web/catalog-templates/shared/ui/catalog-template-form.tsx b/modules/resources/src/web/catalog-templates/shared/ui/catalog-template-form.tsx index 7703f8a..dbfd3e8 100644 --- a/modules/resources/src/web/catalog-templates/shared/ui/catalog-template-form.tsx +++ b/modules/resources/src/web/catalog-templates/shared/ui/catalog-template-form.tsx @@ -50,13 +50,13 @@ export function CatalogTemplateForm(props: CatalogTemplateFormProps) { id={`${id}-usage`} label={t("templateUsage")} onValueChange={(value) => { - if (value === "application" || value === "certificate") { + if (value === "application" || value === "certificate" || value === "support") { form.setValue("usage", value); } else { form.setValue("usage", undefined); } }} - options={["general", "application", "certificate"].map((value) => ({ + options={["general", "application", "certificate", "support"].map((value) => ({ value, label: t(`usage.${value}`), }))} diff --git a/modules/resources/src/web/catalog/list/controllers/use-catalog-page-controller.ts b/modules/resources/src/web/catalog/list/controllers/use-catalog-page-controller.ts index c820b0c..66ab5c9 100644 --- a/modules/resources/src/web/catalog/list/controllers/use-catalog-page-controller.ts +++ b/modules/resources/src/web/catalog/list/controllers/use-catalog-page-controller.ts @@ -43,7 +43,10 @@ export function useResourceCatalogPageController({ scope }: ResourceCatalogPageP mutationFn: ({ item, active }: { item: CatalogItemDTO; active: boolean | null }) => catalogApi.status(scope, item.id, active), onSuccess: async (response) => { - await client.invalidateQueries({ queryKey: ["resource-catalog"] }); + await Promise.all([ + client.invalidateQueries({ queryKey: ["resource-catalog"] }), + client.invalidateQueries({ queryKey: ["asset-support"] }), + ]); client.setQueryData(catalogKeys.scope(scope), response); }, }); diff --git a/modules/resources/src/web/catalog/shared/controllers/use-catalog-save.ts b/modules/resources/src/web/catalog/shared/controllers/use-catalog-save.ts index 1c12029..6fd2505 100644 --- a/modules/resources/src/web/catalog/shared/controllers/use-catalog-save.ts +++ b/modules/resources/src/web/catalog/shared/controllers/use-catalog-save.ts @@ -21,7 +21,10 @@ export function useCatalogSave(scope: string, onSaved: CatalogSaved, id?: string return useMutation({ mutationFn: (values: ResourceDefinitionSpec) => catalogApi.save(scope, values, id), onSuccess: async (response) => { - await client.invalidateQueries({ queryKey: ["resource-catalog"] }); + await Promise.all([ + client.invalidateQueries({ queryKey: ["resource-catalog"] }), + client.invalidateQueries({ queryKey: ["asset-support"] }), + ]); client.setQueryData(catalogKeys.scope(scope), { items: response.items }); if (mounted.current) { onSaved(response.savedId, response); diff --git a/modules/resources/src/web/resources/shared/controllers/use-resource-form-controller.ts b/modules/resources/src/web/resources/shared/controllers/use-resource-form-controller.ts index 3115321..e88d695 100644 --- a/modules/resources/src/web/resources/shared/controllers/use-resource-form-controller.ts +++ b/modules/resources/src/web/resources/shared/controllers/use-resource-form-controller.ts @@ -50,7 +50,9 @@ export function useResourceFormController({ (item) => item.values.kind === "template" && item.effectiveActive && - ((initialKind !== "application" && initialKind !== "certificate") || + ((initialKind !== "application" && + initialKind !== "certificate" && + initialKind !== "support") || item.builtinKey === `template:${initialKind}` || item.values.usage === initialKind) ); @@ -77,6 +79,7 @@ export function useResourceFormController({ ["application-configurations"], ["certificate-configurations"], ["remote-connections", companyId], + ["asset-support", companyId], ].map((queryKey) => client.invalidateQueries({ queryKey })) ); if (mounted.current) { diff --git a/modules/resources/src/web/resources/shared/helpers/resource-form-values.ts b/modules/resources/src/web/resources/shared/helpers/resource-form-values.ts index 0ef6889..3421685 100644 --- a/modules/resources/src/web/resources/shared/helpers/resource-form-values.ts +++ b/modules/resources/src/web/resources/shared/helpers/resource-form-values.ts @@ -50,7 +50,7 @@ export function requireMailboxAddress( export function resourceDefaults( items: readonly CatalogItemDTO[], resource: ResourceDTO | undefined, - initialKind: "mailbox" | "access" | "application" | "certificate" | undefined, + initialKind: "mailbox" | "access" | "application" | "certificate" | "support" | undefined, initialAddress: string, initialIntegration: ResourceInput["integration"] ): ResourceInput { diff --git a/modules/resources/src/web/resources/shared/resources.test.tsx b/modules/resources/src/web/resources/shared/resources.test.tsx index c10b7c5..bbe6bc4 100644 --- a/modules/resources/src/web/resources/shared/resources.test.tsx +++ b/modules/resources/src/web/resources/shared/resources.test.tsx @@ -139,3 +139,27 @@ test("licencia: formulario reutilizable con campos opcionales y ayuda antes de g assert.equal(html.includes("licenseDescription"), false); } }); + +test("asistencia: alta de recurso con plantilla específica y características opcionales", async () => { + const catalog = buildCatalogResponse(seedResourceCatalog(ResourceCatalog.create()), Maybe.none()); + const template = catalog.items.find((item) => item.builtinKey === "template:support"); + + assert.ok(template && template.values.kind === "template"); + assert.equal(template.values.usage, "support"); + assert.ok(template.values.fields.every((field) => !field.required)); + const html = await render( + "es", + undefined} + /> + ); + + assert.ok(html.includes("Herramienta de asistencia remota")); + assert.ok(html.includes("Proveedor")); + assert.ok(html.includes("URL")); + assert.equal(html.includes('aria-label="Nueva plantilla"'), false); +}); diff --git a/modules/resources/src/web/resources/shared/types/resource-editor-props.ts b/modules/resources/src/web/resources/shared/types/resource-editor-props.ts index d0ebb54..132fbf8 100644 --- a/modules/resources/src/web/resources/shared/types/resource-editor-props.ts +++ b/modules/resources/src/web/resources/shared/types/resource-editor-props.ts @@ -1,7 +1,7 @@ export type ResourceEditorProps = { companyId: string; resourceId?: string | undefined; - initialKind?: "mailbox" | "access" | "application" | "certificate" | undefined; + initialKind?: "mailbox" | "access" | "application" | "certificate" | "support" | undefined; initialAddress?: string; initialAccess?: | { diff --git a/modules/resources/src/web/resources/shared/types/resource-form-props.ts b/modules/resources/src/web/resources/shared/types/resource-form-props.ts index f064cea..d11b586 100644 --- a/modules/resources/src/web/resources/shared/types/resource-form-props.ts +++ b/modules/resources/src/web/resources/shared/types/resource-form-props.ts @@ -7,7 +7,7 @@ export type ResourceFormProps = { items: readonly CatalogItemDTO[]; onSaved: (id: string) => void; allowQuickCreate?: boolean; - initialKind?: "mailbox" | "access" | "application" | "certificate" | undefined; + initialKind?: "mailbox" | "access" | "application" | "certificate" | "support" | undefined; initialAddress?: string; initialIntegration?: ResourceInput["integration"]; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 079d41f..ae12a58 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -148,6 +148,9 @@ importers: '@metis/core': specifier: workspace:* version: link:../core + '@metis/resources': + specifier: workspace:* + version: link:../resources '@repo/rdx-ddd': specifier: workspace:* version: link:../../packages/rdx-ddd