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 telework_authorizations");
|
||||||
await database.query("DELETE FROM asset_support_revisions");
|
await database.query("DELETE FROM asset_support_revisions");
|
||||||
await database.query("DELETE FROM asset_support");
|
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_profile_revisions");
|
||||||
await database.query("DELETE FROM work_profiles");
|
await database.query("DELETE FROM work_profiles");
|
||||||
await database.query("DELETE FROM workstation_assets");
|
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,
|
secondCompany.id,
|
||||||
database
|
database
|
||||||
);
|
);
|
||||||
await verifySupport((...args) => operator.request(...args), customer.id, secondCompany.id);
|
await verifySupport(
|
||||||
|
(...args) => operator.request(...args),
|
||||||
|
customer.id,
|
||||||
|
secondCompany.id,
|
||||||
|
database
|
||||||
|
);
|
||||||
await verifyInterventionTemplates(
|
await verifyInterventionTemplates(
|
||||||
(...args) => operator.request(...args),
|
(...args) => operator.request(...args),
|
||||||
customer.id,
|
customer.id,
|
||||||
|
|||||||
@ -24,8 +24,8 @@ export async function createIdentityRuntime(
|
|||||||
identity.authenticate,
|
identity.authenticate,
|
||||||
options.legacyResourceFixtures === true && environment.NODE_ENV === "test"
|
options.legacyResourceFixtures === true && environment.NODE_ENV === "test"
|
||||||
);
|
);
|
||||||
const assets = await createAssetModule(database, identity.authenticate);
|
|
||||||
const resources = await createResourceModule(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 mail = await companies.initializeMail(assets.services.inventory);
|
||||||
const profiles = await companies.initializeProfiles(
|
const profiles = await companies.initializeProfiles(
|
||||||
assets.services.inventory,
|
assets.services.inventory,
|
||||||
|
|||||||
@ -58,8 +58,8 @@ export async function verifyResources(
|
|||||||
|
|
||||||
const initial = await read(global);
|
const initial = await read(global);
|
||||||
|
|
||||||
assert.equal(initial.items.length, 26);
|
assert.equal(initial.items.length, 27);
|
||||||
assert.equal(initial.items.filter((item) => item.values.kind === "template").length, 6);
|
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) => item.builtin && item.id[14] === "7"));
|
||||||
assert.ok(
|
assert.ok(
|
||||||
initial.items.every((item) =>
|
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,
|
InventoryResponseSchema,
|
||||||
SupportListSchema,
|
SupportListSchema,
|
||||||
SupportResponseSchema,
|
SupportResponseSchema,
|
||||||
SupportToolResponseSchema,
|
|
||||||
SupportToolsSchema,
|
|
||||||
} from "@metis/assets/common";
|
} from "@metis/assets/common";
|
||||||
|
import {
|
||||||
|
CatalogResponseSchema,
|
||||||
|
ResourceResponseSchema,
|
||||||
|
SaveDefinitionResponseSchema,
|
||||||
|
} from "@metis/resources/common";
|
||||||
|
import type { Sequelize } from "sequelize";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
import { verifySupportResourceMigration } from "./support-resource-migration.integration";
|
||||||
|
|
||||||
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
|
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(
|
const host = InventoryResponseSchema.parse(
|
||||||
await (await request(`/assets/${companyId}`)).json()
|
await (await request(`/assets/${companyId}`)).json()
|
||||||
).assets.find((item) => item.values.status !== "retired");
|
).assets.find((item) => item.values.status !== "retired");
|
||||||
|
|
||||||
assert.ok(host);
|
assert.ok(host);
|
||||||
const tools = SupportToolsSchema.parse(
|
await verifySupportResourceMigration(database, companyId, host.id);
|
||||||
await (await request("/asset-support/tools")).json()
|
const catalog = CatalogResponseSchema.parse(
|
||||||
).tools;
|
await (await request(`/resource-catalog/${companyId}`)).json()
|
||||||
const tool = tools.find((item) => item.name === "AnyDesk");
|
);
|
||||||
|
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);
|
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 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 = {
|
const values = {
|
||||||
toolId: tool.id,
|
resourceId: tool.id,
|
||||||
destination: "soporte-test",
|
destination: "soporte-test",
|
||||||
requiresVpn: false,
|
requiresVpn: false,
|
||||||
preferred: true,
|
preferred: true,
|
||||||
@ -61,7 +88,7 @@ export async function verifySupport(request: Request, companyId: string, otherId
|
|||||||
await error(path, { values }, 409, "PREFERRED_EXISTS");
|
await error(path, { values }, 409, "PREFERRED_EXISTS");
|
||||||
const secondValues = {
|
const secondValues = {
|
||||||
...values,
|
...values,
|
||||||
toolId: tools.find((item) => item.name === "Supremo Control")?.id,
|
resourceId: tools.find((item) => item.name === "Supremo Control")?.id,
|
||||||
preferred: false,
|
preferred: false,
|
||||||
};
|
};
|
||||||
const second = await request(path, { values: secondValues });
|
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}?unknown=1`)).status, 400);
|
||||||
assert.equal((await request(path, { values }, "http://evil.example")).status, 403);
|
assert.equal((await request(path, { values }, "http://evil.example")).status, 403);
|
||||||
assert.equal((await request(path, { values }, undefined, "invalid-token")).status, 403);
|
assert.equal((await request(path, { values }, undefined, "invalid-token")).status, 403);
|
||||||
const custom = await request("/asset-support/tools", {
|
const custom = await request(`/resources/${companyId}`, {
|
||||||
values: { name: "RustDesk test", active: true },
|
values: {
|
||||||
|
name: "RustDesk test",
|
||||||
|
templateId: template.id,
|
||||||
|
active: true,
|
||||||
|
notes: "",
|
||||||
|
characteristics: {},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(custom.status, 201);
|
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(
|
assert.equal(disabled.status, 200);
|
||||||
"/asset-support/tools",
|
|
||||||
{ values: { name: "rustdesk TEST", active: true } },
|
|
||||||
409,
|
|
||||||
"TOOL_EXISTS"
|
|
||||||
);
|
|
||||||
await error(
|
|
||||||
`/asset-support/tools/${tool.id}/update`,
|
|
||||||
{ values: { name: "Renamed", active: true } },
|
|
||||||
409,
|
|
||||||
"BUILTIN_PROTECTED"
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
(
|
|
||||||
await request(`/asset-support/tools/${customTool.id}/update`, {
|
|
||||||
values: { name: customTool.name, active: false },
|
|
||||||
})
|
|
||||||
).status,
|
|
||||||
200
|
|
||||||
);
|
|
||||||
await error(
|
await error(
|
||||||
path,
|
path,
|
||||||
{ values: { ...values, toolId: customTool.id, preferred: false } },
|
{ values: { ...values, resourceId: customTool.id, preferred: false } },
|
||||||
409,
|
409,
|
||||||
"TOOL_UNAVAILABLE"
|
"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());
|
const listed = SupportListSchema.parse(await (await request(path)).json());
|
||||||
|
|
||||||
assert.equal(listed.connections.length, 2);
|
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
|
## 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
|
## Tickets
|
||||||
|
|
||||||
|
|||||||
@ -1,34 +1,38 @@
|
|||||||
# Asistencia técnica remota
|
# 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
- Las herramientas pueden añadirse, editarse y activarse/desactivarse. Los nombres de serie están protegidos. Desactivar impide seleccionarlas en altas y cambios hacia esa herramienta, mantiene las referencias existentes y muestra un aviso de revisión.
|
|
||||||
|
|
||||||
Los formularios son modales. El desplegable de herramienta usa SelectWithAction para el alta rápida, conservando el formulario padre y seleccionando el elemento creado. La ayuda precede a Guardar/Cancelar. La gestión del catálogo también se abre en modal.
|
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.
|
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
|
## 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:
|
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.
|
||||||
- GET /tools; POST /tools; POST /tools/:id/update.
|
|
||||||
- GET /:companyId/:assetId; POST /:companyId/:assetId.
|
|
||||||
- POST /:companyId/:assetId/:id/update.
|
|
||||||
|
|
||||||
Entradas y salidas estrictas, CSRF y origen aplicados por el servidor. UUID de la compañía y activo validados; los registros ajenos a ese activo no se pueden editar.
|
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
|
## 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.
|
específicos de entidades viven junto a sus consumidores, no en la biblioteca visual genérica.
|
||||||
|
|
||||||
CompanyEditorDialog, PersonEditorDialog, DepartmentEditorDialog, SiteEditorDialog,
|
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
|
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
|
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.
|
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.
|
mutación de confirmación y sus estados pendientes y de error.
|
||||||
|
|
||||||
CompanyEditorDialog, PersonEditorDialog, DepartmentEditorDialog y SiteEditorDialog se reutilizan
|
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
|
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.
|
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)
|
- [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)
|
- [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)
|
- [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)
|
- [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)
|
- [WorkstationEditorDialog](../modules/assets/src/web/workstations/shared/ui/workstation-editor-dialog.tsx)
|
||||||
- [WorkstationHistoryDialog](../modules/assets/src/web/workstations/history/ui/workstation-history-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 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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í |
|
| 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í |
|
| 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 |
|
| 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 |
|
| 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>/`.
|
Los enlaces apuntan a las ubicaciones actuales bajo `modules/<module>/src/web/<entity>/`.
|
||||||
|
|||||||
@ -23,7 +23,8 @@
|
|||||||
"react-hook-form": "7.72.1",
|
"react-hook-form": "7.72.1",
|
||||||
"saxes": "6.0.0",
|
"saxes": "6.0.0",
|
||||||
"sequelize": "6.37.8",
|
"sequelize": "6.37.8",
|
||||||
"zod": "4.3.6"
|
"zod": "4.3.6",
|
||||||
|
"@metis/resources": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@repo/typescript-config": "workspace:*",
|
"@repo/typescript-config": "workspace:*",
|
||||||
|
|||||||
@ -17,9 +17,9 @@ export function mapSupportInput(
|
|||||||
if (!values.isSuccess) {
|
if (!values.isSuccess) {
|
||||||
return values;
|
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"));
|
return Result.fail(new SupportError("TOOL_UNAVAILABLE"));
|
||||||
}
|
}
|
||||||
if (context.assetRetired && input.state !== "retired") {
|
if (context.assetRetired && input.state !== "retired") {
|
||||||
@ -44,7 +44,7 @@ export function mapSupportInput(
|
|||||||
return (
|
return (
|
||||||
item.id.toPrimitive() !== previous?.id.toPrimitive() &&
|
item.id.toPrimitive() !== previous?.id.toPrimitive() &&
|
||||||
other.state !== "retired" &&
|
other.state !== "retired" &&
|
||||||
other.toolId === next.toolId &&
|
other.resourceId === next.resourceId &&
|
||||||
other.destination.toLowerCase() === next.destination.toLowerCase()
|
other.destination.toLowerCase() === next.destination.toLowerCase()
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
import type { SupportConnectionDTO, SupportToolDTO } from "../../common/support";
|
import type { SupportConnectionDTO, SupportToolDTO } from "../../common/support";
|
||||||
import type { SupportConnection } from "../domain/support-connection.aggregate";
|
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 {
|
return {
|
||||||
id: tool.id.toPrimitive(),
|
id: tool.id.toPrimitive(),
|
||||||
name: tool.name.toPrimitive(),
|
name: tool.name,
|
||||||
active: tool.active,
|
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 { Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import type { SupportConnection } from "../domain/support-connection.aggregate";
|
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";
|
import type { SupportError } from "../domain/support-values";
|
||||||
|
|
||||||
export type SupportContext = Readonly<{
|
export type SupportContext = Readonly<{
|
||||||
connections: readonly SupportConnection[];
|
connections: readonly SupportConnection[];
|
||||||
tools: readonly SupportTool[];
|
tools: readonly SupportResource[];
|
||||||
assetRetired: boolean;
|
assetRetired: boolean;
|
||||||
actorNames: ReadonlyMap<string, string>;
|
actorNames: ReadonlyMap<string, string>;
|
||||||
}>;
|
}>;
|
||||||
@ -22,8 +24,4 @@ export interface ISupportRepository {
|
|||||||
): Promise<
|
): Promise<
|
||||||
Result<{ connection: SupportConnection; actorNames: ReadonlyMap<string, string> }, SupportError>
|
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 { Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import { SupportConnection } from "../domain/support-connection.aggregate";
|
import { SupportConnection } from "../domain/support-connection.aggregate";
|
||||||
import { SupportTool } from "../domain/support-tool.aggregate";
|
import type { SupportInput } from "../domain/support-values";
|
||||||
import type { SupportInput, SupportToolInput } from "../domain/support-values";
|
|
||||||
import { SupportError } from "../domain/support-values";
|
import { SupportError } from "../domain/support-values";
|
||||||
|
|
||||||
import type { ISupportRepository } from "./support.repository";
|
import type { ISupportRepository } from "./support.repository";
|
||||||
@ -35,10 +34,6 @@ export class SupportUseCases {
|
|||||||
return this.repository.remove(company.data, asset.data, identity.data);
|
return this.repository.remove(company.data, asset.data, identity.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
tools() {
|
|
||||||
return this.repository.tools();
|
|
||||||
}
|
|
||||||
|
|
||||||
save(
|
save(
|
||||||
companyId: string,
|
companyId: string,
|
||||||
assetId: string,
|
assetId: string,
|
||||||
@ -78,35 +73,4 @@ export class SupportUseCases {
|
|||||||
: SupportConnection.create(company.data, asset.data, values.data, actor.data, now);
|
: SupportConnection.create(company.data, asset.data, values.data, actor.data, now);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
saveTool(id: string | undefined, input: SupportToolInput) {
|
|
||||||
const identity = id ? UniqueID.create(id) : undefined;
|
|
||||||
|
|
||||||
if (identity && !identity.isSuccess) {
|
|
||||||
return Promise.resolve(Result.fail(new SupportError("INVALID_REQUEST")));
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.repository.saveTool((tools) => {
|
|
||||||
const previous = identity?.isSuccess
|
|
||||||
? tools.find((item) => item.id.equals(identity.data))
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
if (id && !previous) {
|
|
||||||
return Result.fail(new SupportError("TOOL_NOT_FOUND"));
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
tools.some(
|
|
||||||
(item) =>
|
|
||||||
item.id.toPrimitive() !== previous?.id.toPrimitive() &&
|
|
||||||
item.name
|
|
||||||
.toPrimitive()
|
|
||||||
.localeCompare(input.name.trim(), "en", { sensitivity: "base" }) === 0
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return Result.fail(new SupportError("TOOL_EXISTS"));
|
|
||||||
}
|
|
||||||
|
|
||||||
return previous ? previous.update(input) : SupportTool.create(input);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
export const SupportValuesSchema = z
|
||||||
.strictObject({
|
.strictObject({
|
||||||
toolId: z.uuid(),
|
resourceId: z.uuid(),
|
||||||
destination: z.string().trim().max(300),
|
destination: z.string().trim().max(300),
|
||||||
requiresVpn: z.boolean(),
|
requiresVpn: z.boolean(),
|
||||||
preferred: z.boolean(),
|
preferred: z.boolean(),
|
||||||
@ -13,13 +13,6 @@ export const SupportValuesSchema = z
|
|||||||
|
|
||||||
export type SupportInput = z.infer<typeof SupportValuesSchema>;
|
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 {
|
export class SupportError extends Error {
|
||||||
constructor(public readonly code: string) {
|
constructor(public readonly code: string) {
|
||||||
super(code);
|
super(code);
|
||||||
|
|||||||
@ -7,19 +7,15 @@ import { mapSupportInput } from "../application/support-input.mapper";
|
|||||||
import { SupportPersistenceMapper } from "../infrastructure/persistence/support-persistence.mapper";
|
import { SupportPersistenceMapper } from "../infrastructure/persistence/support-persistence.mapper";
|
||||||
|
|
||||||
import { SupportConnection } from "./support-connection.aggregate";
|
import { SupportConnection } from "./support-connection.aggregate";
|
||||||
import { SupportTool } from "./support-tool.aggregate";
|
|
||||||
import { SupportValuesSchema } from "./support-values";
|
import { SupportValuesSchema } from "./support-values";
|
||||||
import { SupportValues } from "./value-objects/support-values.vo";
|
import { SupportValues } from "./value-objects/support-values.vo";
|
||||||
|
|
||||||
const company = UniqueID.generateNewID();
|
const company = UniqueID.generateNewID();
|
||||||
const asset = UniqueID.generateNewID();
|
const asset = UniqueID.generateNewID();
|
||||||
const actor = UniqueID.generateNewID();
|
const actor = UniqueID.generateNewID();
|
||||||
const toolResult = SupportTool.create({ name: "AnyDesk", active: true }, true);
|
const tool = { id: UniqueID.generateNewID(), name: "AnyDesk", active: true };
|
||||||
|
|
||||||
assert.ok(toolResult.isSuccess);
|
|
||||||
const tool = toolResult.data;
|
|
||||||
const input = {
|
const input = {
|
||||||
toolId: tool.id.toPrimitive(),
|
resourceId: tool.id.toPrimitive(),
|
||||||
destination: "123 456",
|
destination: "123 456",
|
||||||
requiresVpn: false,
|
requiresVpn: false,
|
||||||
preferred: 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", () => {
|
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, active: false };
|
||||||
const disabled = tool.update({ name: "AnyDesk", active: false });
|
|
||||||
|
|
||||||
assert.ok(disabled.isSuccess);
|
|
||||||
const context = {
|
const context = {
|
||||||
connections: [],
|
connections: [],
|
||||||
tools: [disabled.data],
|
tools: [disabled],
|
||||||
assetRetired: false,
|
assetRetired: false,
|
||||||
actorNames: new Map(),
|
actorNames: new Map(),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -6,8 +6,8 @@ import { SupportError, SupportValuesSchema } from "../support-values";
|
|||||||
|
|
||||||
export class SupportValues {
|
export class SupportValues {
|
||||||
private constructor(
|
private constructor(
|
||||||
public readonly toolId: UniqueID,
|
public readonly resourceId: UniqueID,
|
||||||
private readonly data: Omit<SupportInput, "toolId">
|
private readonly data: Omit<SupportInput, "resourceId">
|
||||||
) {
|
) {
|
||||||
Object.freeze(data);
|
Object.freeze(data);
|
||||||
Object.freeze(this);
|
Object.freeze(this);
|
||||||
@ -15,18 +15,18 @@ export class SupportValues {
|
|||||||
|
|
||||||
static create(input: SupportInput): Result<SupportValues, SupportError> {
|
static create(input: SupportInput): Result<SupportValues, SupportError> {
|
||||||
const parsed = SupportValuesSchema.safeParse(input);
|
const parsed = SupportValuesSchema.safeParse(input);
|
||||||
const tool = UniqueID.create(input.toolId);
|
const tool = UniqueID.create(input.resourceId);
|
||||||
|
|
||||||
if (!(parsed.success && tool.isSuccess)) {
|
if (!(parsed.success && tool.isSuccess)) {
|
||||||
return Result.fail(new SupportError("INVALID_REQUEST"));
|
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));
|
return Result.ok(new SupportValues(tool.data, data));
|
||||||
}
|
}
|
||||||
|
|
||||||
toPrimitive(): SupportInput {
|
toPrimitive(): SupportInput {
|
||||||
return { ...this.data, toolId: this.toolId.toPrimitive() };
|
return { ...this.data, resourceId: this.resourceId.toPrimitive() };
|
||||||
}
|
}
|
||||||
|
|
||||||
equals(other: SupportValues) {
|
equals(other: SupportValues) {
|
||||||
@ -34,7 +34,7 @@ export class SupportValues {
|
|||||||
const b = other.data;
|
const b = other.data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
this.toolId.equals(other.toolId) &&
|
this.resourceId.equals(other.resourceId) &&
|
||||||
a.destination === b.destination &&
|
a.destination === b.destination &&
|
||||||
a.requiresVpn === b.requiresVpn &&
|
a.requiresVpn === b.requiresVpn &&
|
||||||
a.preferred === b.preferred &&
|
a.preferred === b.preferred &&
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import type { Application, RequestHandler, Response } from "express";
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { z } from "zod/v4";
|
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 type { SupportUseCases } from "../../application/support.use-cases";
|
||||||
import { buildSupport, buildSupportTool } from "../../application/support-response.builder";
|
import { buildSupport, buildSupportTool } from "../../application/support-response.builder";
|
||||||
|
|
||||||
@ -22,17 +22,6 @@ export function registerSupportRoutes(
|
|||||||
) {
|
) {
|
||||||
const router = Router();
|
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) => {
|
router.get("/:companyId/:assetId", (request, response, next) => {
|
||||||
const params = Params.safeParse(request.params);
|
const params = Params.safeParse(request.params);
|
||||||
|
|
||||||
@ -58,34 +47,6 @@ export function registerSupportRoutes(
|
|||||||
})
|
})
|
||||||
.catch(next);
|
.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"]) {
|
for (const path of ["/:companyId/:assetId", "/:companyId/:assetId/:id/update"]) {
|
||||||
router.post(path, (request, response, next) => {
|
router.post(path, (request, response, next) => {
|
||||||
const params = (path.endsWith("update") ? Params.extend({ id: z.uuid() }) : Params).safeParse(
|
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 { Result } from "@repo/rdx-utils";
|
||||||
import type { Sequelize, Transaction } from "sequelize";
|
import type { Sequelize, Transaction } from "sequelize";
|
||||||
import { QueryTypes } from "sequelize";
|
import { QueryTypes } from "sequelize";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
import type { ISupportRepository, SupportContext } from "../../application/support.repository";
|
import type { ISupportRepository, SupportContext } from "../../application/support.repository";
|
||||||
import { AssetStatusSchema } from "../../domain/asset-values";
|
import { AssetStatusSchema } from "../../domain/asset-values";
|
||||||
import type { SupportConnection } from "../../domain/support-connection.aggregate";
|
import type { SupportConnection } from "../../domain/support-connection.aggregate";
|
||||||
import type { SupportTool } from "../../domain/support-tool.aggregate";
|
|
||||||
import { SupportError } from "../../domain/support-values";
|
import { SupportError } from "../../domain/support-values";
|
||||||
|
|
||||||
import { SupportPersistenceMapper } from "./support-persistence.mapper";
|
import { SupportPersistenceMapper } from "./support-persistence.mapper";
|
||||||
@ -16,17 +16,28 @@ export class SequelizeSupportRepository implements ISupportRepository {
|
|||||||
|
|
||||||
constructor(private readonly database: Sequelize) {}
|
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>>(
|
const rows = await this.database.query<Record<string, unknown>>(
|
||||||
"SELECT id,name,active,builtin FROM support_tools ORDER BY name",
|
"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",
|
||||||
{ type: QueryTypes.SELECT, ...(transaction ? { transaction } : {}) }
|
{ 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() {
|
if (!id.isSuccess) {
|
||||||
return this.readTools();
|
throw new Error("Recurso de asistencia inconsistente.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...parsed, id: id.data };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get(company: UniqueID, asset: UniqueID) {
|
get(company: UniqueID, asset: UniqueID) {
|
||||||
@ -49,11 +60,11 @@ export class SequelizeSupportRepository implements ISupportRepository {
|
|||||||
}
|
}
|
||||||
const assetStatus = AssetStatusSchema.parse(assets[0].status);
|
const assetStatus = AssetStatusSchema.parse(assets[0].status);
|
||||||
const roots = await this.database.query<Record<string, unknown>>(
|
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 }
|
{ replacements, transaction, type: QueryTypes.SELECT }
|
||||||
);
|
);
|
||||||
const revisions = await this.database.query<Record<string, unknown>>(
|
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 }
|
{ replacements, transaction, type: QueryTypes.SELECT }
|
||||||
);
|
);
|
||||||
const users = await this.database.query<{ id: string; name: string }>(
|
const users = await this.database.query<{ id: string; name: string }>(
|
||||||
@ -63,7 +74,7 @@ export class SequelizeSupportRepository implements ISupportRepository {
|
|||||||
|
|
||||||
return Result.ok({
|
return Result.ok({
|
||||||
assetRetired: assetStatus === "retired",
|
assetRetired: assetStatus === "retired",
|
||||||
tools: await this.readTools(transaction),
|
tools: await this.readTools(company, transaction),
|
||||||
connections: roots.map((root) =>
|
connections: roots.map((root) =>
|
||||||
this.mapper.toDomain(
|
this.mapper.toDomain(
|
||||||
root,
|
root,
|
||||||
@ -133,8 +144,8 @@ export class SequelizeSupportRepository implements ISupportRepository {
|
|||||||
|
|
||||||
await this.database.query(
|
await this.database.query(
|
||||||
context.data.connections.some((item) => item.id.equals(result.data.id))
|
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"
|
? "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,toolId,destination,requiresVpn,preferred,state,notes,preferredKey) VALUES (:id,:companyId,:assetId,:toolId,:destination,:requiresVpn,:preferred,:state,:notes,:preferredKey)",
|
: "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 }
|
{ replacements: data.root, transaction }
|
||||||
);
|
);
|
||||||
const existing = new Set(
|
const existing = new Set(
|
||||||
@ -146,7 +157,7 @@ export class SequelizeSupportRepository implements ISupportRepository {
|
|||||||
for (const row of data.revisions) {
|
for (const row of data.revisions) {
|
||||||
if (!existing.has(row.id)) {
|
if (!existing.has(row.id)) {
|
||||||
await this.database.query(
|
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 }
|
{ replacements: row, transaction }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -155,27 +166,4 @@ export class SequelizeSupportRepository implements ISupportRepository {
|
|||||||
return Result.ok({ connection: result.data, actorNames: context.data.actorNames });
|
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 type { Sequelize } from "sequelize";
|
||||||
import { DataTypes, QueryTypes } from "sequelize";
|
import { DataTypes, QueryTypes } from "sequelize";
|
||||||
|
|
||||||
@ -19,23 +18,13 @@ export async function migrateSupport(database: Sequelize) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const definitions = [
|
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",
|
"asset_support",
|
||||||
{
|
{
|
||||||
id: { type: DataTypes.UUID, primaryKey: true },
|
id: { type: DataTypes.UUID, primaryKey: true },
|
||||||
companyId: fk("companies"),
|
companyId: fk("companies"),
|
||||||
assetId: fk("assets"),
|
assetId: fk("assets"),
|
||||||
toolId: fk("support_tools"),
|
resourceId: { type: DataTypes.UUID, allowNull: false },
|
||||||
...supportColumns,
|
...supportColumns,
|
||||||
preferredKey: { type: DataTypes.UUID, allowNull: true, unique: true },
|
preferredKey: { type: DataTypes.UUID, allowNull: true, unique: true },
|
||||||
},
|
},
|
||||||
@ -46,7 +35,7 @@ export async function migrateSupport(database: Sequelize) {
|
|||||||
id: { type: DataTypes.UUID, primaryKey: true },
|
id: { type: DataTypes.UUID, primaryKey: true },
|
||||||
connectionId: fk("asset_support"),
|
connectionId: fk("asset_support"),
|
||||||
actorId: fk("users"),
|
actorId: fk("users"),
|
||||||
toolId: fk("support_tools"),
|
resourceId: { type: DataTypes.UUID, allowNull: false },
|
||||||
...supportColumns,
|
...supportColumns,
|
||||||
recordedAt: { type: DataTypes.STRING(30), allowNull: false },
|
recordedAt: { type: DataTypes.STRING(30), allowNull: false },
|
||||||
position: { type: DataTypes.INTEGER, 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)"
|
"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)");
|
const resourceIndexes = await database.query<{ name: string }>(
|
||||||
for (const name of ["AnyDesk", "Supremo Control", "Escritorio remoto", "TeamViewer"]) {
|
"SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='resources'",
|
||||||
await database.query(
|
{ type: QueryTypes.SELECT }
|
||||||
"INSERT IGNORE INTO support_tools (id,name,active,builtin) VALUES (:id,:name,1,1)",
|
);
|
||||||
{
|
|
||||||
replacements: { id: UniqueID.generateNewID().toPrimitive(), name },
|
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 {
|
return {
|
||||||
...identity,
|
...identity,
|
||||||
values: {
|
values: {
|
||||||
toolId: row.toolId,
|
resourceId: row.resourceId,
|
||||||
destination,
|
destination,
|
||||||
requiresVpn: z
|
requiresVpn: z
|
||||||
.union([z.boolean(), z.literal(0), z.literal(1)])
|
.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 { z } from "zod/v4";
|
||||||
|
|
||||||
import { SupportConnection } from "../../domain/support-connection.aggregate";
|
import { SupportConnection } from "../../domain/support-connection.aggregate";
|
||||||
import { SupportConnectionRevision } from "../../domain/support-connection-revision.entity";
|
import { SupportConnectionRevision } from "../../domain/support-connection-revision.entity";
|
||||||
import { SupportTool } from "../../domain/support-tool.aggregate";
|
|
||||||
import { SupportValuesSchema } from "../../domain/support-values";
|
import { SupportValuesSchema } from "../../domain/support-values";
|
||||||
import { SupportValues } from "../../domain/value-objects/support-values.vo";
|
import { SupportValues } from "../../domain/value-objects/support-values.vo";
|
||||||
|
|
||||||
@ -33,7 +32,7 @@ const RootSchema = z.strictObject({
|
|||||||
id: z.uuid(),
|
id: z.uuid(),
|
||||||
companyId: z.uuid(),
|
companyId: z.uuid(),
|
||||||
assetId: z.uuid(),
|
assetId: z.uuid(),
|
||||||
toolId: z.uuid(),
|
resourceId: z.uuid(),
|
||||||
values: SupportValuesSchema,
|
values: SupportValuesSchema,
|
||||||
preferredKey: z.uuid().nullable(),
|
preferredKey: z.uuid().nullable(),
|
||||||
});
|
});
|
||||||
@ -41,7 +40,7 @@ const RevisionSchema = z.strictObject({
|
|||||||
id: z.uuid(),
|
id: z.uuid(),
|
||||||
connectionId: z.uuid(),
|
connectionId: z.uuid(),
|
||||||
actorId: z.uuid(),
|
actorId: z.uuid(),
|
||||||
toolId: z.uuid(),
|
resourceId: z.uuid(),
|
||||||
values: SupportValuesSchema,
|
values: SupportValuesSchema,
|
||||||
recordedAt: z.iso.datetime(),
|
recordedAt: z.iso.datetime(),
|
||||||
position: z.number().int().nonnegative(),
|
position: z.number().int().nonnegative(),
|
||||||
@ -82,7 +81,7 @@ export class SupportPersistenceMapper {
|
|||||||
(item, index) =>
|
(item, index) =>
|
||||||
item.connectionId !== current.id ||
|
item.connectionId !== current.id ||
|
||||||
item.position !== index ||
|
item.position !== index ||
|
||||||
item.toolId !== item.values.toolId ||
|
item.resourceId !== item.values.resourceId ||
|
||||||
(index > 0 && item.recordedAt < (history[index - 1]?.recordedAt ?? ""))
|
(index > 0 && item.recordedAt < (history[index - 1]?.recordedAt ?? ""))
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
@ -101,7 +100,7 @@ export class SupportPersistenceMapper {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
current.toolId !== current.values.toolId ||
|
current.resourceId !== current.values.resourceId ||
|
||||||
current.preferredKey !== (current.values.preferred ? current.assetId : null) ||
|
current.preferredKey !== (current.values.preferred ? current.assetId : null) ||
|
||||||
!revisions.at(-1)?.values.equals(currentValues)
|
!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",
|
"choose": "Choose a tool",
|
||||||
"chooseState": "Choose a status",
|
"chooseState": "Choose a status",
|
||||||
"newTool": "New tool",
|
"newTool": "New tool",
|
||||||
"tools": "Assistance tools",
|
|
||||||
"editTool": "Edit tool",
|
|
||||||
"toolName": "Name",
|
|
||||||
"active": "Active",
|
|
||||||
"builtin": "Built-in",
|
|
||||||
"destination": "Identifier or destination (optional)",
|
"destination": "Identifier or destination (optional)",
|
||||||
"requiresVpn": "Requires VPN",
|
"requiresVpn": "Requires VPN",
|
||||||
"preferred": "Preferred method",
|
"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.",
|
"examples": "AnyDesk: 123 456 789; Supremo Control: its identifier; Remote Desktop: PORT-ANA-01, with VPN if required.",
|
||||||
"examplesLabel": "Examples and guidance",
|
"examplesLabel": "Examples and guidance",
|
||||||
"scope": "Do not enter passwords or access keys in the identifier or notes.",
|
"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.",
|
"review": "Review: the asset is retired or the tool is inactive.",
|
||||||
"errors": {
|
"errors": {
|
||||||
"INVALID_REQUEST": "Check the fields. Only an available method can be preferred.",
|
"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_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.",
|
"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.",
|
"CONNECTION_NOT_FOUND": "This method is unavailable.",
|
||||||
"PREFERRED_EXISTS": "A preferred method already exists. Uncheck it before choosing another.",
|
"PREFERRED_EXISTS": "A preferred method already exists. Uncheck it before choosing another.",
|
||||||
"CONNECTION_EXISTS": "This method already exists with the same identifier or destination.",
|
"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."
|
"INTERNAL_ERROR": "The operation could not be completed."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -51,11 +51,6 @@
|
|||||||
"choose": "Selecciona una herramienta",
|
"choose": "Selecciona una herramienta",
|
||||||
"chooseState": "Selecciona un estado",
|
"chooseState": "Selecciona un estado",
|
||||||
"newTool": "Nueva herramienta",
|
"newTool": "Nueva herramienta",
|
||||||
"tools": "Herramientas de asistencia",
|
|
||||||
"editTool": "Editar herramienta",
|
|
||||||
"toolName": "Nombre",
|
|
||||||
"active": "Activa",
|
|
||||||
"builtin": "De serie",
|
|
||||||
"destination": "Identificador o destino (opcional)",
|
"destination": "Identificador o destino (opcional)",
|
||||||
"requiresVpn": "Requiere VPN",
|
"requiresVpn": "Requiere VPN",
|
||||||
"preferred": "Método preferido",
|
"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.",
|
"examples": "AnyDesk: 123 456 789; Supremo Control: su identificador; Escritorio remoto: PORT-ANA-01, con VPN si es necesaria.",
|
||||||
"examplesLabel": "Ejemplos y recomendaciones",
|
"examplesLabel": "Ejemplos y recomendaciones",
|
||||||
"scope": "No introduzcas contraseñas ni claves de acceso en el identificador o las notas.",
|
"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.",
|
"review": "Revisar: el activo está retirado o la herramienta está desactivada.",
|
||||||
"errors": {
|
"errors": {
|
||||||
"INVALID_REQUEST": "Revisa los campos. Solo un método disponible puede marcarse como preferido.",
|
"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_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.",
|
"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.",
|
"CONNECTION_NOT_FOUND": "El método ya no está disponible.",
|
||||||
"PREFERRED_EXISTS": "Ya hay un método preferido. Desmárcalo antes de elegir otro.",
|
"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.",
|
"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."
|
"INTERNAL_ERROR": "No se pudo completar la operación."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,16 +1,15 @@
|
|||||||
import { z } from "zod/v4";
|
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({
|
export const SupportToolSchema = z.strictObject({
|
||||||
id: z.uuid(),
|
id: z.uuid(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
active: z.boolean(),
|
active: z.boolean(),
|
||||||
builtin: z.boolean(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const SupportConnectionSchema = z.strictObject({
|
export const SupportConnectionSchema = z.strictObject({
|
||||||
@ -36,10 +35,6 @@ export const SupportListSchema = z.strictObject({
|
|||||||
|
|
||||||
export const SupportResponseSchema = z.strictObject({ connection: SupportConnectionSchema });
|
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 SupportConnectionDTO = z.infer<typeof SupportConnectionSchema>;
|
||||||
|
|
||||||
export type SupportToolDTO = z.infer<typeof SupportToolSchema>;
|
export type SupportToolDTO = z.infer<typeof SupportToolSchema>;
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
export const supportKeys = {
|
export const supportKeys = {
|
||||||
asset: (companyId: string, assetId: string) => ["asset-support", companyId, assetId],
|
asset: (companyId: string, assetId: string) => ["asset-support", companyId, assetId],
|
||||||
tools: ["support-tools"],
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -14,7 +14,7 @@ export function SupportSummary({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-1 text-sm">
|
<div className="space-y-1 text-sm">
|
||||||
<p className="font-medium">
|
<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}`)}
|
{t(`support.${values.state}`)}
|
||||||
{values.preferred ? ` · ${t("support.preferred")}` : ""}
|
{values.preferred ? ` · ${t("support.preferred")}` : ""}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -13,7 +13,6 @@ export function useAssetSupportController({ companyId, assetId }: AssetSupportPr
|
|||||||
});
|
});
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [edit, setEdit] = useState<SupportConnectionDTO | undefined>();
|
const [edit, setEdit] = useState<SupportConnectionDTO | undefined>();
|
||||||
const [toolsOpen, setToolsOpen] = useState(false);
|
|
||||||
const [showRetired, setShowRetired] = useState(false);
|
const [showRetired, setShowRetired] = useState(false);
|
||||||
const [historyId, setHistoryId] = useState<string>();
|
const [historyId, setHistoryId] = useState<string>();
|
||||||
const history = query.data?.connections.find((connection) => connection.id === historyId);
|
const history = query.data?.connections.find((connection) => connection.id === historyId);
|
||||||
@ -27,8 +26,6 @@ export function useAssetSupportController({ companyId, assetId }: AssetSupportPr
|
|||||||
setOpen,
|
setOpen,
|
||||||
edit,
|
edit,
|
||||||
setEdit,
|
setEdit,
|
||||||
toolsOpen,
|
|
||||||
setToolsOpen,
|
|
||||||
showRetired,
|
showRetired,
|
||||||
setShowRetired,
|
setShowRetired,
|
||||||
setHistoryId,
|
setHistoryId,
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import { useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { DeleteRecordDialog } from "../../../shared/ui/delete-record-dialog";
|
import { DeleteRecordDialog } from "../../../shared/ui/delete-record-dialog";
|
||||||
import { SupportToolsDialog } from "../../../support-tools";
|
|
||||||
import { SupportSummary } from "../../detail";
|
import { SupportSummary } from "../../detail";
|
||||||
import { SupportHistoryDialog } from "../../history";
|
import { SupportHistoryDialog } from "../../history";
|
||||||
import { SupportConnectionEditorDialog } from "../../shared";
|
import { SupportConnectionEditorDialog } from "../../shared";
|
||||||
@ -18,8 +17,6 @@ export function AssetSupport(props: AssetSupportProps) {
|
|||||||
setOpen,
|
setOpen,
|
||||||
edit,
|
edit,
|
||||||
setEdit,
|
setEdit,
|
||||||
toolsOpen,
|
|
||||||
setToolsOpen,
|
|
||||||
showRetired,
|
showRetired,
|
||||||
setShowRetired,
|
setShowRetired,
|
||||||
setHistoryId,
|
setHistoryId,
|
||||||
@ -35,9 +32,6 @@ export function AssetSupport(props: AssetSupportProps) {
|
|||||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||||
{!compact && <h3 className="font-semibold">{t("support.title")}</h3>}
|
{!compact && <h3 className="font-semibold">{t("support.title")}</h3>}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button onClick={() => setToolsOpen(true)} size="sm" variant="outline">
|
|
||||||
{t("support.tools")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
disabled={!query.data || retired}
|
disabled={!query.data || retired}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -82,7 +76,8 @@ export function AssetSupport(props: AssetSupportProps) {
|
|||||||
<li className="space-y-2 py-3" key={item.id}>
|
<li className="space-y-2 py-3" key={item.id}>
|
||||||
<SupportSummary tools={tools} values={item.values} />
|
<SupportSummary tools={tools} values={item.values} />
|
||||||
{item.values.state !== "retired" &&
|
{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>
|
<p className="text-sm text-amber-700">{t("support.review")}</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@ -125,7 +120,6 @@ export function AssetSupport(props: AssetSupportProps) {
|
|||||||
open={open}
|
open={open}
|
||||||
tools={tools}
|
tools={tools}
|
||||||
/>
|
/>
|
||||||
<SupportToolsDialog onOpenChange={setToolsOpen} open={toolsOpen} />
|
|
||||||
<SupportHistoryDialog
|
<SupportHistoryDialog
|
||||||
history={history}
|
history={history}
|
||||||
onOpenChange={(value) => {
|
onOpenChange={(value) => {
|
||||||
|
|||||||
@ -20,7 +20,7 @@ export function useSupportFormController({
|
|||||||
const form = useForm<SupportInput>({
|
const form = useForm<SupportInput>({
|
||||||
resolver: zodResolver(SupportValuesSchema),
|
resolver: zodResolver(SupportValuesSchema),
|
||||||
defaultValues: connection?.values ?? {
|
defaultValues: connection?.values ?? {
|
||||||
toolId: "",
|
resourceId: "",
|
||||||
destination: "",
|
destination: "",
|
||||||
requiresVpn: false,
|
requiresVpn: false,
|
||||||
preferred: false,
|
preferred: false,
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import { renderToStaticMarkup } from "react-dom/server";
|
|||||||
import { I18nextProvider } from "react-i18next";
|
import { I18nextProvider } from "react-i18next";
|
||||||
|
|
||||||
import { assetsEnglish, assetsSpanish } from "../../shared";
|
import { assetsEnglish, assetsSpanish } from "../../shared";
|
||||||
import { SupportToolForm } from "../../support-tools";
|
|
||||||
import { SupportSummary } from "../detail";
|
import { SupportSummary } from "../detail";
|
||||||
|
|
||||||
import { SupportForm } from "./ui";
|
import { SupportForm } from "./ui";
|
||||||
@ -18,7 +17,6 @@ const tool = {
|
|||||||
id: UniqueID.generateNewID().toPrimitive(),
|
id: UniqueID.generateNewID().toPrimitive(),
|
||||||
name: "AnyDesk",
|
name: "AnyDesk",
|
||||||
active: true,
|
active: true,
|
||||||
builtin: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
async function render(node: ReactNode, language = "es") {
|
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 () => {
|
test("asistencia: traducciones y resumen de método", async () => {
|
||||||
const values = {
|
const values = {
|
||||||
toolId: tool.id,
|
resourceId: tool.id,
|
||||||
destination: "PORT-ANA-01",
|
destination: "PORT-ANA-01",
|
||||||
requiresVpn: true,
|
requiresVpn: true,
|
||||||
preferred: true,
|
preferred: true,
|
||||||
@ -79,9 +77,3 @@ test("asistencia: traducciones y resumen de método", async () => {
|
|||||||
assert.ok(html.includes("AnyDesk"));
|
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 { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { SupportFeedback } from "../../../shared";
|
import { SupportFeedback } from "../../../shared";
|
||||||
import { SupportToolEditorDialog } from "../../../support-tools";
|
|
||||||
import { useSupportFormController } from "../controllers";
|
import { useSupportFormController } from "../controllers";
|
||||||
import type { SupportFormProps } from "../types";
|
import type { SupportFormProps } from "../types";
|
||||||
|
|
||||||
import { SupportHelp } from "./support-help";
|
import { SupportHelp } from "./support-help";
|
||||||
|
import { SupportResourceCreateDialog } from "./support-resource-create-dialog";
|
||||||
|
|
||||||
export function SupportForm(props: SupportFormProps) {
|
export function SupportForm(props: SupportFormProps) {
|
||||||
const { tools, connection } = props;
|
const { tools, connection } = props;
|
||||||
@ -31,12 +31,12 @@ export function SupportForm(props: SupportFormProps) {
|
|||||||
action={{ label: t("support.newTool"), onClick: () => setQuick(true) }}
|
action={{ label: t("support.newTool"), onClick: () => setQuick(true) }}
|
||||||
id={prefix}
|
id={prefix}
|
||||||
label={t("support.tool")}
|
label={t("support.tool")}
|
||||||
onValueChange={(id) => form.setValue("toolId", id)}
|
onValueChange={(id) => form.setValue("resourceId", id)}
|
||||||
options={tools
|
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 }))}
|
.map((item) => ({ value: item.id, label: item.name }))}
|
||||||
placeholder={t("support.choose")}
|
placeholder={t("support.choose")}
|
||||||
value={values.toolId}
|
value={values.resourceId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@ -103,11 +103,11 @@ export function SupportForm(props: SupportFormProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</FormActions>
|
</FormActions>
|
||||||
</form>
|
</form>
|
||||||
<SupportToolEditorDialog
|
<SupportResourceCreateDialog
|
||||||
edit={undefined}
|
companyId={props.companyId}
|
||||||
onOpenChange={setQuick}
|
onOpenChange={setQuick}
|
||||||
onSaved={(id) => {
|
onSaved={(id) => {
|
||||||
form.setValue("toolId", id);
|
form.setValue("resourceId", id);
|
||||||
setQuick(false);
|
setQuick(false);
|
||||||
}}
|
}}
|
||||||
open={quick}
|
open={quick}
|
||||||
|
|||||||
@ -1,17 +1,17 @@
|
|||||||
import { ContextHelp } from "@repo/rdx-ui/components/context-help";
|
import { ContextHelp } from "@repo/rdx-ui/components/context-help";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export function SupportHelp({ tool = false }: { tool?: boolean }) {
|
export function SupportHelp() {
|
||||||
const { t } = useTranslation("assets");
|
const { t } = useTranslation("assets");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextHelp
|
<ContextHelp
|
||||||
description={t(tool ? "support.toolDescription" : "support.description")}
|
description={t("support.description")}
|
||||||
examples={t(tool ? "support.toolExamples" : "support.examples")}
|
examples={t("support.examples")}
|
||||||
examplesLabel={t("support.examplesLabel")}
|
examplesLabel={t("support.examplesLabel")}
|
||||||
guidance={t(tool ? "support.toolGuidance" : "support.guidance")}
|
guidance={t("support.guidance")}
|
||||||
scopeNote={tool ? undefined : t("support.scope")}
|
scopeNote={t("support.scope")}
|
||||||
title={t(tool ? "support.toolHelp" : "support.help")}
|
title={t("support.help")}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,19 +1,17 @@
|
|||||||
|
import { ResourceEditor } from "@metis/resources/client";
|
||||||
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||||
import type { ComponentProps } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { SupportToolForm } from "./support-tool-form";
|
export function SupportResourceCreateDialog({
|
||||||
|
companyId,
|
||||||
export function SupportToolEditorDialog({
|
|
||||||
open,
|
open,
|
||||||
edit,
|
|
||||||
onSaved,
|
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
onSaved,
|
||||||
}: {
|
}: {
|
||||||
|
companyId: string;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
edit: ComponentProps<typeof SupportToolForm>["tool"];
|
|
||||||
onSaved: ComponentProps<typeof SupportToolForm>["onSaved"];
|
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSaved: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("assets");
|
const { t } = useTranslation("assets");
|
||||||
|
|
||||||
@ -23,11 +21,13 @@ export function SupportToolEditorDialog({
|
|||||||
formActions
|
formActions
|
||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
open={open}
|
open={open}
|
||||||
title={t(edit ? "support.editTool" : "support.newTool")}
|
title={t("support.newTool")}
|
||||||
|
wide
|
||||||
>
|
>
|
||||||
<SupportToolForm
|
<ResourceEditor
|
||||||
key={edit?.id ?? "new"}
|
allowQuickCreate={false}
|
||||||
{...(edit ? { tool: edit } : {})}
|
companyId={companyId}
|
||||||
|
initialKind="support"
|
||||||
onSaved={onSaved}
|
onSaved={onSaved}
|
||||||
/>
|
/>
|
||||||
</FormDialog>
|
</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 {
|
resources: readonly {
|
||||||
id: UniqueID;
|
id: UniqueID;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
usage: "application" | "certificate" | null;
|
usage: "application" | "certificate" | "support" | null;
|
||||||
}[];
|
}[];
|
||||||
actors: ReadonlyMap<string, string>;
|
actors: ReadonlyMap<string, string>;
|
||||||
}>;
|
}>;
|
||||||
|
|||||||
@ -14,7 +14,7 @@ export type CertificateContext = Readonly<{
|
|||||||
resources: readonly {
|
resources: readonly {
|
||||||
id: UniqueID;
|
id: UniqueID;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
usage: "application" | "certificate" | null;
|
usage: "application" | "certificate" | "support" | null;
|
||||||
expiresOn: string | null;
|
expiresOn: string | null;
|
||||||
}[];
|
}[];
|
||||||
actors: ReadonlyMap<string, string>;
|
actors: ReadonlyMap<string, string>;
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import type { UniqueID } from "@repo/rdx-ddd";
|
import type { UniqueID } from "@repo/rdx-ddd";
|
||||||
|
|
||||||
|
import type { ResourceDefinitionSpec } from "../domain/resource-definition";
|
||||||
|
|
||||||
import type { IResourceRepository } from "./resource.repository";
|
import type { IResourceRepository } from "./resource.repository";
|
||||||
|
|
||||||
export interface IResourceInventoryService {
|
export interface IResourceInventoryService {
|
||||||
@ -8,7 +10,7 @@ export interface IResourceInventoryService {
|
|||||||
id: UniqueID;
|
id: UniqueID;
|
||||||
name: string;
|
name: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
usage: "application" | "certificate" | null;
|
usage: "application" | "certificate" | "support" | null;
|
||||||
expiresOn: string | null;
|
expiresOn: string | null;
|
||||||
}[]
|
}[]
|
||||||
>;
|
>;
|
||||||
@ -33,14 +35,7 @@ export class ResourceInventoryService implements IResourceInventoryService {
|
|||||||
);
|
);
|
||||||
const data = template?.values.data;
|
const data = template?.values.data;
|
||||||
const key = template?.stockKey.getOrUndefined();
|
const key = template?.stockKey.getOrUndefined();
|
||||||
const usage: "application" | "certificate" | null =
|
const usage = resourceUsage(key, data);
|
||||||
key === "template:application"
|
|
||||||
? "application"
|
|
||||||
: key === "template:certificate"
|
|
||||||
? "certificate"
|
|
||||||
: data?.kind === "template"
|
|
||||||
? (data.usage ?? null)
|
|
||||||
: null;
|
|
||||||
const expiresOn = expiryFieldId
|
const expiresOn = expiryFieldId
|
||||||
? resource.values.toPrimitive().characteristics[expiryFieldId]
|
? resource.values.toPrimitive().characteristics[expiryFieldId]
|
||||||
: null;
|
: 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"]],
|
["mailbox", "Buzón de correo", ["email", "provider", "context"]],
|
||||||
["access", "Recurso de acceso", ["reference"]],
|
["access", "Recurso de acceso", ["reference"]],
|
||||||
["shared_folder", "Carpeta compartida", ["unc", "server", "description"]],
|
["shared_folder", "Carpeta compartida", ["unc", "server", "description"]],
|
||||||
|
["support", "Herramienta de asistencia remota", ["provider", "url", "description"]],
|
||||||
["application", "Aplicación", ["provider", "url", "description"]],
|
["application", "Aplicación", ["provider", "url", "description"]],
|
||||||
["certificate", "Certificado digital", ["holder", "issuer", "expires", "fingerprint", "purpose"]],
|
["certificate", "Certificado digital", ["holder", "issuer", "expires", "fingerprint", "purpose"]],
|
||||||
[
|
[
|
||||||
@ -116,6 +117,7 @@ export function seedResourceCatalog(catalog: ResourceCatalog) {
|
|||||||
|
|
||||||
add(`template:${key}`, {
|
add(`template:${key}`, {
|
||||||
kind: "template",
|
kind: "template",
|
||||||
|
...(key === "support" ? { usage: "support" as const } : {}),
|
||||||
name,
|
name,
|
||||||
description: "",
|
description: "",
|
||||||
fields: references,
|
fields: references,
|
||||||
|
|||||||
@ -29,7 +29,7 @@ const field = {
|
|||||||
test("catálogo de recursos: serie idempotente, UUID v7, características opcionales y protección", () => {
|
test("catálogo de recursos: serie idempotente, UUID v7, características opcionales y protección", () => {
|
||||||
const catalog = seedResourceCatalog(ResourceCatalog.create());
|
const catalog = seedResourceCatalog(ResourceCatalog.create());
|
||||||
|
|
||||||
assert.equal(catalog.definitions.length, 26);
|
assert.equal(catalog.definitions.length, 27);
|
||||||
assert.equal(seedResourceCatalog(catalog), catalog);
|
assert.equal(seedResourceCatalog(catalog), catalog);
|
||||||
assert.ok(catalog.definitions.every((item) => item.id.toPrimitive()[14] === "7"));
|
assert.ok(catalog.definitions.every((item) => item.id.toPrimitive()[14] === "7"));
|
||||||
const template = catalog.definitions.find((item) => item.values.data.kind === "template");
|
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);
|
assert.ok(disabled.isSuccess);
|
||||||
const upgraded = seedResourceCatalog(disabled.data);
|
const upgraded = seedResourceCatalog(disabled.data);
|
||||||
|
|
||||||
assert.equal(upgraded.definitions.length, 26);
|
assert.equal(upgraded.definitions.length, 27);
|
||||||
for (const existing of disabled.data.definitions) {
|
for (const existing of disabled.data.definitions) {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
upgraded.definitions.find((item) => item.id.equals(existing.id)),
|
upgraded.definitions.find((item) => item.id.equals(existing.id)),
|
||||||
|
|||||||
@ -34,7 +34,7 @@ export const ResourceDefinitionSchema = z.discriminatedUnion("kind", [
|
|||||||
description: z.string().max(1000),
|
description: z.string().max(1000),
|
||||||
fields: z.array(TemplateFieldSchema).min(1).max(100),
|
fields: z.array(TemplateFieldSchema).min(1).max(100),
|
||||||
sections: z.array(TemplateSectionSchema).max(30).optional(),
|
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), {
|
.refine((template) => validTemplateSections(template.sections ?? [], template.fields), {
|
||||||
path: ["sections"],
|
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 { registerResourceRoutes } from "./infrastructure/http/resource.routes";
|
||||||
import { registerResourceCatalogRoutes } from "./infrastructure/http/resource-catalog.routes";
|
import { registerResourceCatalogRoutes } from "./infrastructure/http/resource-catalog.routes";
|
||||||
import { migrateLegacyResources } from "./infrastructure/persistence/migrate-legacy-resources";
|
import { migrateLegacyResources } from "./infrastructure/persistence/migrate-legacy-resources";
|
||||||
|
import { migrateSupportResources } from "./infrastructure/persistence/migrate-support-resources";
|
||||||
import { migrateInventory } from "./infrastructure/persistence/resource.model";
|
import { migrateInventory } from "./infrastructure/persistence/resource.model";
|
||||||
import {
|
import {
|
||||||
ResourceCatalogModel,
|
ResourceCatalogModel,
|
||||||
@ -40,6 +41,7 @@ export async function createResourceModule(database: Sequelize, authenticate: Re
|
|||||||
}
|
}
|
||||||
await migrateInventory(database);
|
await migrateInventory(database);
|
||||||
await enforceResourceConstraints(database);
|
await enforceResourceConstraints(database);
|
||||||
|
await migrateSupportResources(database);
|
||||||
const catalog = new ResourceCatalogUseCases(repository);
|
const catalog = new ResourceCatalogUseCases(repository);
|
||||||
const inventoryRepository = new SequelizeResourceRepository(database);
|
const inventoryRepository = new SequelizeResourceRepository(database);
|
||||||
const inventory = new ResourceUseCases(inventoryRepository);
|
const inventory = new ResourceUseCases(inventoryRepository);
|
||||||
|
|||||||
@ -21,7 +21,7 @@
|
|||||||
"history": "History",
|
"history": "History",
|
||||||
"recordedBy": "Recorded by {{name}}",
|
"recordedBy": "Recorded by {{name}}",
|
||||||
"snapshotNotice": "This record retains the template fields captured when the resource was created.",
|
"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.",
|
"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.",
|
"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.",
|
"resourceScope": "This record identifies the company resource. Availability and device configuration are managed separately.",
|
||||||
@ -58,11 +58,12 @@
|
|||||||
"editField": "Edit field",
|
"editField": "Edit field",
|
||||||
"newTemplate": "New template",
|
"newTemplate": "New template",
|
||||||
"templateUsage": "Template use",
|
"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": {
|
"usage": {
|
||||||
"general": "General",
|
"general": "General",
|
||||||
"application": "Application",
|
"application": "Application",
|
||||||
"certificate": "Digital certificate"
|
"certificate": "Digital certificate",
|
||||||
|
"support": "Remote assistance"
|
||||||
},
|
},
|
||||||
"editTemplate": "Edit template",
|
"editTemplate": "Edit template",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
|
|||||||
@ -21,7 +21,7 @@
|
|||||||
"history": "Historial",
|
"history": "Historial",
|
||||||
"recordedBy": "Registrado por {{name}}",
|
"recordedBy": "Registrado por {{name}}",
|
||||||
"snapshotNotice": "La ficha conserva los campos de la plantilla utilizados al dar de alta el recurso.",
|
"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.",
|
"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.",
|
"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.",
|
"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",
|
"editField": "Editar campo",
|
||||||
"newTemplate": "Nueva plantilla",
|
"newTemplate": "Nueva plantilla",
|
||||||
"templateUsage": "Uso de la 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": {
|
"usage": {
|
||||||
"general": "General",
|
"general": "General",
|
||||||
"application": "Aplicación",
|
"application": "Aplicación",
|
||||||
"certificate": "Certificado digital"
|
"certificate": "Certificado digital",
|
||||||
|
"support": "Asistencia remota"
|
||||||
},
|
},
|
||||||
"editTemplate": "Editar plantilla",
|
"editTemplate": "Editar plantilla",
|
||||||
"name": "Nombre",
|
"name": "Nombre",
|
||||||
|
|||||||
@ -50,13 +50,13 @@ export function CatalogTemplateForm(props: CatalogTemplateFormProps) {
|
|||||||
id={`${id}-usage`}
|
id={`${id}-usage`}
|
||||||
label={t("templateUsage")}
|
label={t("templateUsage")}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
if (value === "application" || value === "certificate") {
|
if (value === "application" || value === "certificate" || value === "support") {
|
||||||
form.setValue("usage", value);
|
form.setValue("usage", value);
|
||||||
} else {
|
} else {
|
||||||
form.setValue("usage", undefined);
|
form.setValue("usage", undefined);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
options={["general", "application", "certificate"].map((value) => ({
|
options={["general", "application", "certificate", "support"].map((value) => ({
|
||||||
value,
|
value,
|
||||||
label: t(`usage.${value}`),
|
label: t(`usage.${value}`),
|
||||||
}))}
|
}))}
|
||||||
|
|||||||
@ -43,7 +43,10 @@ export function useResourceCatalogPageController({ scope }: ResourceCatalogPageP
|
|||||||
mutationFn: ({ item, active }: { item: CatalogItemDTO; active: boolean | null }) =>
|
mutationFn: ({ item, active }: { item: CatalogItemDTO; active: boolean | null }) =>
|
||||||
catalogApi.status(scope, item.id, active),
|
catalogApi.status(scope, item.id, active),
|
||||||
onSuccess: async (response) => {
|
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);
|
client.setQueryData(catalogKeys.scope(scope), response);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -21,7 +21,10 @@ export function useCatalogSave(scope: string, onSaved: CatalogSaved, id?: string
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (values: ResourceDefinitionSpec) => catalogApi.save(scope, values, id),
|
mutationFn: (values: ResourceDefinitionSpec) => catalogApi.save(scope, values, id),
|
||||||
onSuccess: async (response) => {
|
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 });
|
client.setQueryData(catalogKeys.scope(scope), { items: response.items });
|
||||||
if (mounted.current) {
|
if (mounted.current) {
|
||||||
onSaved(response.savedId, response);
|
onSaved(response.savedId, response);
|
||||||
|
|||||||
@ -50,7 +50,9 @@ export function useResourceFormController({
|
|||||||
(item) =>
|
(item) =>
|
||||||
item.values.kind === "template" &&
|
item.values.kind === "template" &&
|
||||||
item.effectiveActive &&
|
item.effectiveActive &&
|
||||||
((initialKind !== "application" && initialKind !== "certificate") ||
|
((initialKind !== "application" &&
|
||||||
|
initialKind !== "certificate" &&
|
||||||
|
initialKind !== "support") ||
|
||||||
item.builtinKey === `template:${initialKind}` ||
|
item.builtinKey === `template:${initialKind}` ||
|
||||||
item.values.usage === initialKind)
|
item.values.usage === initialKind)
|
||||||
);
|
);
|
||||||
@ -77,6 +79,7 @@ export function useResourceFormController({
|
|||||||
["application-configurations"],
|
["application-configurations"],
|
||||||
["certificate-configurations"],
|
["certificate-configurations"],
|
||||||
["remote-connections", companyId],
|
["remote-connections", companyId],
|
||||||
|
["asset-support", companyId],
|
||||||
].map((queryKey) => client.invalidateQueries({ queryKey }))
|
].map((queryKey) => client.invalidateQueries({ queryKey }))
|
||||||
);
|
);
|
||||||
if (mounted.current) {
|
if (mounted.current) {
|
||||||
|
|||||||
@ -50,7 +50,7 @@ export function requireMailboxAddress(
|
|||||||
export function resourceDefaults(
|
export function resourceDefaults(
|
||||||
items: readonly CatalogItemDTO[],
|
items: readonly CatalogItemDTO[],
|
||||||
resource: ResourceDTO | undefined,
|
resource: ResourceDTO | undefined,
|
||||||
initialKind: "mailbox" | "access" | "application" | "certificate" | undefined,
|
initialKind: "mailbox" | "access" | "application" | "certificate" | "support" | undefined,
|
||||||
initialAddress: string,
|
initialAddress: string,
|
||||||
initialIntegration: ResourceInput["integration"]
|
initialIntegration: ResourceInput["integration"]
|
||||||
): ResourceInput {
|
): ResourceInput {
|
||||||
|
|||||||
@ -139,3 +139,27 @@ test("licencia: formulario reutilizable con campos opcionales y ayuda antes de g
|
|||||||
assert.equal(html.includes("licenseDescription"), false);
|
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 = {
|
export type ResourceEditorProps = {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
resourceId?: string | undefined;
|
resourceId?: string | undefined;
|
||||||
initialKind?: "mailbox" | "access" | "application" | "certificate" | undefined;
|
initialKind?: "mailbox" | "access" | "application" | "certificate" | "support" | undefined;
|
||||||
initialAddress?: string;
|
initialAddress?: string;
|
||||||
initialAccess?:
|
initialAccess?:
|
||||||
| {
|
| {
|
||||||
|
|||||||
@ -7,7 +7,7 @@ export type ResourceFormProps = {
|
|||||||
items: readonly CatalogItemDTO[];
|
items: readonly CatalogItemDTO[];
|
||||||
onSaved: (id: string) => void;
|
onSaved: (id: string) => void;
|
||||||
allowQuickCreate?: boolean;
|
allowQuickCreate?: boolean;
|
||||||
initialKind?: "mailbox" | "access" | "application" | "certificate" | undefined;
|
initialKind?: "mailbox" | "access" | "application" | "certificate" | "support" | undefined;
|
||||||
initialAddress?: string;
|
initialAddress?: string;
|
||||||
initialIntegration?: ResourceInput["integration"];
|
initialIntegration?: ResourceInput["integration"];
|
||||||
};
|
};
|
||||||
|
|||||||
@ -148,6 +148,9 @@ importers:
|
|||||||
'@metis/core':
|
'@metis/core':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../core
|
version: link:../core
|
||||||
|
'@metis/resources':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../resources
|
||||||
'@repo/rdx-ddd':
|
'@repo/rdx-ddd':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/rdx-ddd
|
version: link:../../packages/rdx-ddd
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user