herramientas de soporte convertidos en recursos
This commit is contained in:
parent
a0b88f1364
commit
8c5b7b4df0
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) =>
|
||||
|
||||
149
apps/server/src/support-resource-migration.integration.ts
Normal file
149
apps/server/src/support-resource-migration.integration.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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>/`.
|
||||
|
||||
@ -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:*",
|
||||
|
||||
@ -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()
|
||||
);
|
||||
})
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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>>;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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(),
|
||||
};
|
||||
|
||||
@ -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 &&
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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."
|
||||
}
|
||||
},
|
||||
|
||||
@ -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."
|
||||
}
|
||||
},
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
export const supportKeys = {
|
||||
asset: (companyId: string, assetId: string) => ["asset-support", companyId, assetId],
|
||||
tools: ["support-tools"],
|
||||
};
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -20,7 +20,7 @@ export function useSupportFormController({
|
||||
const form = useForm<SupportInput>({
|
||||
resolver: zodResolver(SupportValuesSchema),
|
||||
defaultValues: connection?.values ?? {
|
||||
toolId: "",
|
||||
resourceId: "",
|
||||
destination: "",
|
||||
requiresVpn: false,
|
||||
preferred: false,
|
||||
|
||||
@ -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"));
|
||||
});
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
@ -1,3 +0,0 @@
|
||||
export { SupportToolsDialog } from "./list";
|
||||
|
||||
export { SupportToolEditorDialog, SupportToolForm } from "./shared";
|
||||
@ -1 +0,0 @@
|
||||
export { listSupportTools } from "./list-support-tools.api";
|
||||
@ -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"));
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
export { useSupportToolsController } from "./use-support-tools-controller";
|
||||
@ -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 };
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
export { SupportToolsDialog } from "./ui";
|
||||
@ -1 +0,0 @@
|
||||
export { SupportToolsDialog } from "./support-tools-dialog";
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
export { saveSupportTool } from "./save-support-tool.api";
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
export { useSupportToolFormController } from "./use-support-tool-form-controller";
|
||||
@ -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 };
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
export { SupportToolEditorDialog, SupportToolForm } from "./ui";
|
||||
@ -1 +0,0 @@
|
||||
export type { SupportToolFormProps } from "./support-tool-form-props";
|
||||
@ -1,6 +0,0 @@
|
||||
import type { SupportToolDTO } from "../../../../common/support";
|
||||
|
||||
export type SupportToolFormProps = {
|
||||
tool?: SupportToolDTO;
|
||||
onSaved: (id: string) => void;
|
||||
};
|
||||
@ -1,3 +0,0 @@
|
||||
export { SupportToolEditorDialog } from "./support-tool-editor-dialog";
|
||||
|
||||
export { SupportToolForm } from "./support-tool-form";
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>;
|
||||
}>;
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)),
|
||||
|
||||
@ -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"],
|
||||
|
||||
@ -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 }
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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}`),
|
||||
}))}
|
||||
|
||||
@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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?:
|
||||
| {
|
||||
|
||||
@ -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"];
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user