herramientas de soporte convertidos en recursos

This commit is contained in:
David Arranz 2026-10-10 18:21:06 +02:00
parent a0b88f1364
commit 8c5b7b4df0
73 changed files with 736 additions and 709 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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/<module>/src/web/<entity>/`.

View File

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

View File

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

View File

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

View File

@ -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<string, string>;
}>;
@ -22,8 +24,4 @@ export interface ISupportRepository {
): Promise<
Result<{ connection: SupportConnection; actorNames: ReadonlyMap<string, string> }, SupportError>
>;
tools(): Promise<readonly SupportTool[]>;
saveTool(
action: (tools: readonly SupportTool[]) => Result<SupportTool, SupportError>
): Promise<Result<SupportTool, SupportError>>;
}

View File

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

View File

@ -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<Props> {
private constructor(props: Props, id?: UniqueID) {
super(Object.freeze({ ...props }), id);
}
static create(input: SupportToolInput, builtin = false): Result<SupportTool, SupportError> {
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<SupportTool, SupportError> {
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)
);
}
}

View File

@ -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<typeof SupportValuesSchema>;
export const SupportToolInputSchema = z.strictObject({
name: z.string().trim().min(1).max(100),
active: z.boolean(),
});
export type SupportToolInput = z.infer<typeof SupportToolInputSchema>;
export class SupportError extends Error {
constructor(public readonly code: string) {
super(code);

View File

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

View File

@ -6,8 +6,8 @@ import { SupportError, SupportValuesSchema } from "../support-values";
export class SupportValues {
private constructor(
public readonly toolId: UniqueID,
private readonly data: Omit<SupportInput, "toolId">
public readonly resourceId: UniqueID,
private readonly data: Omit<SupportInput, "resourceId">
) {
Object.freeze(data);
Object.freeze(this);
@ -15,18 +15,18 @@ export class SupportValues {
static create(input: SupportInput): Result<SupportValues, SupportError> {
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 &&

View File

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

View File

@ -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<Record<string, unknown>>(
"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<Record<string, unknown>>(
"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<Record<string, unknown>>(
"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<SupportTool, SupportError>) {
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;
});
}
}

View File

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

View File

@ -4,7 +4,7 @@ function connectionRow(row: Record<string, unknown>) {
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<string, unknown>) {
};
}
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<string, unknown>) {
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,
};
}
}

View File

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

View File

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

View File

@ -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<typeof SupportConnectionSchema>;
export type SupportToolDTO = z.infer<typeof SupportToolSchema>;

View File

@ -1,4 +1,3 @@
export const supportKeys = {
asset: (companyId: string, assetId: string) => ["asset-support", companyId, assetId],
tools: ["support-tools"],
};

View File

@ -14,7 +14,7 @@ export function SupportSummary({
return (
<div className="space-y-1 text-sm">
<p className="font-medium">
{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")}` : ""}
</p>

View File

@ -13,7 +13,6 @@ export function useAssetSupportController({ companyId, assetId }: AssetSupportPr
});
const [open, setOpen] = useState(false);
const [edit, setEdit] = useState<SupportConnectionDTO | undefined>();
const [toolsOpen, setToolsOpen] = useState(false);
const [showRetired, setShowRetired] = useState(false);
const [historyId, setHistoryId] = useState<string>();
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,

View File

@ -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) {
<header className="flex flex-wrap items-center justify-between gap-3">
{!compact && <h3 className="font-semibold">{t("support.title")}</h3>}
<div className="flex flex-wrap gap-2">
<Button onClick={() => setToolsOpen(true)} size="sm" variant="outline">
{t("support.tools")}
</Button>
<Button
disabled={!query.data || retired}
onClick={() => {
@ -82,7 +76,8 @@ export function AssetSupport(props: AssetSupportProps) {
<li className="space-y-2 py-3" key={item.id}>
<SupportSummary tools={tools} values={item.values} />
{item.values.state !== "retired" &&
(retired || !tools.find((tool) => tool.id === item.values.toolId)?.active) && (
(retired ||
!tools.find((tool) => tool.id === item.values.resourceId)?.active) && (
<p className="text-sm text-amber-700">{t("support.review")}</p>
)}
<div className="flex flex-wrap gap-2">
@ -125,7 +120,6 @@ export function AssetSupport(props: AssetSupportProps) {
open={open}
tools={tools}
/>
<SupportToolsDialog onOpenChange={setToolsOpen} open={toolsOpen} />
<SupportHistoryDialog
history={history}
onOpenChange={(value) => {

View File

@ -20,7 +20,7 @@ export function useSupportFormController({
const form = useForm<SupportInput>({
resolver: zodResolver(SupportValuesSchema),
defaultValues: connection?.values ?? {
toolId: "",
resourceId: "",
destination: "",
requiresVpn: false,
preferred: false,

View File

@ -9,7 +9,6 @@ import { renderToStaticMarkup } from "react-dom/server";
import { I18nextProvider } from "react-i18next";
import { assetsEnglish, assetsSpanish } from "../../shared";
import { SupportToolForm } from "../../support-tools";
import { SupportSummary } from "../detail";
import { SupportForm } from "./ui";
@ -18,7 +17,6 @@ const tool = {
id: UniqueID.generateNewID().toPrimitive(),
name: "AnyDesk",
active: true,
builtin: true,
};
async function render(node: ReactNode, language = "es") {
@ -60,7 +58,7 @@ test("asistencia: formulario con alta rápida y ayuda antes de guardar", async (
});
test("asistencia: traducciones y resumen de método", async () => {
const values = {
toolId: tool.id,
resourceId: tool.id,
destination: "PORT-ANA-01",
requiresVpn: true,
preferred: true,
@ -79,9 +77,3 @@ test("asistencia: traducciones y resumen de método", async () => {
assert.ok(html.includes("AnyDesk"));
}
});
test("herramienta: nombre de serie protegido y ayuda inferior", async () => {
const html = await render(<SupportToolForm onSaved={() => undefined} tool={tool} />);
assert.ok(html.includes("readOnly"));
assert.ok(html.indexOf("¿Para qué sirve una herramienta?") < html.indexOf("Guardar"));
});

View File

@ -8,11 +8,11 @@ import { useId } from "react";
import { useTranslation } from "react-i18next";
import { SupportFeedback } from "../../../shared";
import { SupportToolEditorDialog } from "../../../support-tools";
import { useSupportFormController } from "../controllers";
import type { SupportFormProps } from "../types";
import { SupportHelp } from "./support-help";
import { SupportResourceCreateDialog } from "./support-resource-create-dialog";
export function SupportForm(props: SupportFormProps) {
const { tools, connection } = props;
@ -31,12 +31,12 @@ export function SupportForm(props: SupportFormProps) {
action={{ label: t("support.newTool"), onClick: () => setQuick(true) }}
id={prefix}
label={t("support.tool")}
onValueChange={(id) => form.setValue("toolId", id)}
onValueChange={(id) => form.setValue("resourceId", id)}
options={tools
.filter((item) => item.active || item.id === connection?.values.toolId)
.filter((item) => item.active || item.id === connection?.values.resourceId)
.map((item) => ({ value: item.id, label: item.name }))}
placeholder={t("support.choose")}
value={values.toolId}
value={values.resourceId}
/>
</div>
<div className="space-y-2">
@ -103,11 +103,11 @@ export function SupportForm(props: SupportFormProps) {
</Button>
</FormActions>
</form>
<SupportToolEditorDialog
edit={undefined}
<SupportResourceCreateDialog
companyId={props.companyId}
onOpenChange={setQuick}
onSaved={(id) => {
form.setValue("toolId", id);
form.setValue("resourceId", id);
setQuick(false);
}}
open={quick}

View File

@ -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 (
<ContextHelp
description={t(tool ? "support.toolDescription" : "support.description")}
examples={t(tool ? "support.toolExamples" : "support.examples")}
description={t("support.description")}
examples={t("support.examples")}
examplesLabel={t("support.examplesLabel")}
guidance={t(tool ? "support.toolGuidance" : "support.guidance")}
scopeNote={tool ? undefined : t("support.scope")}
title={t(tool ? "support.toolHelp" : "support.help")}
guidance={t("support.guidance")}
scopeNote={t("support.scope")}
title={t("support.help")}
/>
);
}

View File

@ -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<typeof SupportToolForm>["tool"];
onSaved: ComponentProps<typeof SupportToolForm>["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
>
<SupportToolForm
key={edit?.id ?? "new"}
{...(edit ? { tool: edit } : {})}
<ResourceEditor
allowQuickCreate={false}
companyId={companyId}
initialKind="support"
onSaved={onSaved}
/>
</FormDialog>

View File

@ -1,3 +0,0 @@
export { SupportToolsDialog } from "./list";
export { SupportToolEditorDialog, SupportToolForm } from "./shared";

View File

@ -1 +0,0 @@
export { listSupportTools } from "./list-support-tools.api";

View File

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

View File

@ -1 +0,0 @@
export { useSupportToolsController } from "./use-support-tools-controller";

View File

@ -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<SupportToolDTO | undefined>();
const [open, setOpen] = useState(false);
return { query, edit, setEdit, open, setOpen };
}

View File

@ -1 +0,0 @@
export { SupportToolsDialog } from "./ui";

View File

@ -1 +0,0 @@
export { SupportToolsDialog } from "./support-tools-dialog";

View File

@ -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 (
<FormDialog
cancelLabel={t("cancel")}
onOpenChange={onOpenChange}
open={open}
title={t("support.tools")}
>
<SupportTools />
</FormDialog>
);
}

View File

@ -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 (
<div className="space-y-4">
<EntityListHeader
actions={
<>
{" "}
<Button
onClick={() => {
setEdit(undefined);
setOpen(true);
}}
>
{t("support.newTool")}
</Button>{" "}
</>
}
locale={i18n.language}
title={t("support.tools")}
/>
{query.isPending && <p role="status">{t("support.loading")}</p>}
{query.isError && (
<div role="alert">
<p>{t("support.loadError")}</p>
<Button
onClick={() => {
query.refetch().catch(() => undefined);
}}
variant="outline"
>
{t("retry")}
</Button>
</div>
)}
<EntityTable
columns={[
{
id: "name",
label: t("name"),
value: (tool) => tool.name,
hideable: false,
render: (tool) => <span className="font-medium">{tool.name}</span>,
},
{
id: "state",
label: t("status"),
value: (tool) => tool.active,
render: (tool) => (
<EntityStatus active={tool.active}>
{t(tool.active ? "support.active" : "inactive")}
</EntityStatus>
),
},
{
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) => (
<EntityRowActions label={t("actions")}>
<EntityAction
onClick={() => {
setEdit(tool);
setOpen(true);
}}
>
{t("edit")}
</EntityAction>
</EntityRowActions>
),
},
]}
items={query.data?.tools ?? []}
loading={query.isPending}
locale={i18n.language}
title={t("support.tools")}
unavailable={query.isError}
/>
<SupportToolEditorDialog
edit={edit}
onOpenChange={setOpen}
onSaved={() => setOpen(false)}
open={open}
/>
</div>
);
}

View File

@ -1 +0,0 @@
export { saveSupportTool } from "./save-support-tool.api";

View File

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

View File

@ -1 +0,0 @@
export { useSupportToolFormController } from "./use-support-tool-form-controller";

View File

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

View File

@ -1 +0,0 @@
export { SupportToolEditorDialog, SupportToolForm } from "./ui";

View File

@ -1 +0,0 @@
export type { SupportToolFormProps } from "./support-tool-form-props";

View File

@ -1,6 +0,0 @@
import type { SupportToolDTO } from "../../../../common/support";
export type SupportToolFormProps = {
tool?: SupportToolDTO;
onSaved: (id: string) => void;
};

View File

@ -1,3 +0,0 @@
export { SupportToolEditorDialog } from "./support-tool-editor-dialog";
export { SupportToolForm } from "./support-tool-form";

View File

@ -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 className="flex min-h-0 flex-1 flex-col overflow-hidden" onSubmit={submit}>
<FormBody>
<fieldset className="space-y-4" disabled={mutation.isPending}>
<div className="space-y-2">
<Label htmlFor={id}>{t("support.toolName")}</Label>
<Input
id={id}
maxLength={100}
readOnly={tool?.builtin}
required
{...form.register("name")}
/>
</div>
<SwitchField
checked={form.watch("active")}
id={`${id}-active`}
label={t("support.active")}
onCheckedChange={(active) => form.setValue("active", active, { shouldDirty: true })}
/>
</fieldset>
<SupportFeedback
error={mutation.error}
invalid={Object.keys(form.formState.errors).length > 0}
/>
<SupportHelp tool />
</FormBody>
<FormActions>
<Button disabled={mutation.isPending} type="submit">
{t(mutation.isPending ? "saving" : "save")}
</Button>
</FormActions>
</form>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Record<string, unknown>>(
"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<typeof ToolSchema>,
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 }
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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",
<ResourceForm
allowQuickCreate={false}
companyId={companyId}
initialKind="support"
items={catalog.items}
onSaved={() => 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);
});

View File

@ -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?:
| {

View File

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

View File

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