Asistencia técnica remota
This commit is contained in:
parent
dffb4b2794
commit
ea1e5e450f
@ -34,6 +34,7 @@ import { verifyRelationships } from "./relationships.integration";
|
||||
import { verifyRemoteConnections } from "./remote.integration";
|
||||
import { verifyResourceMigration } from "./resource-migration.integration";
|
||||
import { verifyResources } from "./resources.integration";
|
||||
import { verifySupport } from "./support.integration";
|
||||
import { verifyTelework } from "./telework.integration";
|
||||
import { verifyWorkProfiles } from "./work-profiles.integration";
|
||||
import { verifyWorkstations } from "./workstations.integration";
|
||||
@ -75,6 +76,10 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
|
||||
await database.query("DELETE FROM remote_connections");
|
||||
await database.query("DELETE FROM telework_authorization_revisions");
|
||||
await database.query("DELETE FROM telework_authorizations");
|
||||
await database.query("DELETE FROM asset_support_revisions");
|
||||
await database.query("DELETE FROM asset_support");
|
||||
await database.query("DELETE FROM support_tools WHERE builtin=0");
|
||||
await database.query("UPDATE support_tools SET active=1 WHERE builtin=1");
|
||||
await database.query("DELETE FROM work_profile_revisions");
|
||||
await database.query("DELETE FROM work_profiles");
|
||||
await database.query("DELETE FROM workstation_assets");
|
||||
@ -812,6 +817,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
|
||||
secondCompany.id,
|
||||
database
|
||||
);
|
||||
await verifySupport((...args) => operator.request(...args), customer.id, secondCompany.id);
|
||||
// El entorno normal bloquea las fichas antiguas; el modo de fixtures anterior no se usa aquí.
|
||||
const migratedRuntime = await createIdentityRuntime(database, environment);
|
||||
const migratedServer = createApp(() => undefined, migratedRuntime).listen(0, "127.0.0.1");
|
||||
@ -897,6 +903,12 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
|
||||
);
|
||||
await expectError(await operator.request(`/processes/${customer.id}`), 401, "UNAUTHENTICATED");
|
||||
await expectError(await operator.request("/processes/templates"), 401, "UNAUTHENTICATED");
|
||||
await expectError(await operator.request("/asset-support/tools"), 401, "UNAUTHENTICATED");
|
||||
await expectError(
|
||||
await operator.request(`/asset-support/${customer.id}/${customer.id}`),
|
||||
401,
|
||||
"UNAUTHENTICATED"
|
||||
);
|
||||
await expectError(
|
||||
await operator.request(`/processes/${customer.id}`, {}),
|
||||
401,
|
||||
|
||||
145
apps/server/src/support.integration.ts
Normal file
145
apps/server/src/support.integration.ts
Normal file
@ -0,0 +1,145 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import {
|
||||
InventoryResponseSchema,
|
||||
SupportListSchema,
|
||||
SupportResponseSchema,
|
||||
SupportToolResponseSchema,
|
||||
SupportToolsSchema,
|
||||
} from "@metis/assets/common";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
|
||||
export async function verifySupport(request: Request, companyId: string, otherId: string) {
|
||||
const host = InventoryResponseSchema.parse(
|
||||
await (await request(`/assets/${companyId}`)).json()
|
||||
).assets.find((item) => item.values.status !== "retired");
|
||||
assert.ok(host);
|
||||
const tools = SupportToolsSchema.parse(
|
||||
await (await request("/asset-support/tools")).json()
|
||||
).tools;
|
||||
const tool = tools.find((item) => item.name === "AnyDesk");
|
||||
assert.ok(tool);
|
||||
for (const name of ["AnyDesk", "Supremo Control", "Escritorio remoto", "TeamViewer"]) {
|
||||
assert.ok(tools.some((item) => item.name === name));
|
||||
}
|
||||
const path = `/asset-support/${companyId}/${host.id}`;
|
||||
const values = {
|
||||
toolId: tool.id,
|
||||
destination: "soporte-test",
|
||||
requiresVpn: false,
|
||||
preferred: true,
|
||||
state: "available" as const,
|
||||
notes: "Test",
|
||||
};
|
||||
async function error(url: string, body: unknown, status: number, code: string) {
|
||||
const response = await request(url, body);
|
||||
assert.equal(response.status, status);
|
||||
assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code);
|
||||
}
|
||||
const saved = await request(path, { values });
|
||||
assert.equal(saved.status, 201, await saved.clone().text());
|
||||
const first = SupportResponseSchema.parse(await saved.json()).connection;
|
||||
assert.equal(first.id[14], "7");
|
||||
assert.equal(first.history.length, 1);
|
||||
await error(`/asset-support/${otherId}/${host.id}`, { values }, 404, "ASSET_NOT_FOUND");
|
||||
await error(
|
||||
path,
|
||||
{ values: { ...values, preferred: true, state: "pending" } },
|
||||
400,
|
||||
"INVALID_REQUEST"
|
||||
);
|
||||
await error(path, { values }, 409, "PREFERRED_EXISTS");
|
||||
const secondValues = {
|
||||
...values,
|
||||
toolId: tools.find((item) => item.name === "Supremo Control")?.id,
|
||||
preferred: false,
|
||||
};
|
||||
const second = await request(path, { values: secondValues });
|
||||
assert.equal(second.status, 201);
|
||||
const secondItem = SupportResponseSchema.parse(await second.json()).connection;
|
||||
await error(path, { values: secondValues }, 409, "CONNECTION_EXISTS");
|
||||
await error(`${path}/${randomUUID()}/update`, { values }, 404, "CONNECTION_NOT_FOUND");
|
||||
const same = await request(`${path}/${first.id}/update`, { values });
|
||||
assert.equal(same.status, 200);
|
||||
assert.equal(SupportResponseSchema.parse(await same.json()).connection.history.length, 1);
|
||||
const update = await request(`${path}/${first.id}/update`, {
|
||||
values: { ...values, requiresVpn: true },
|
||||
});
|
||||
assert.equal(update.status, 200);
|
||||
const changed = SupportResponseSchema.parse(await update.json()).connection;
|
||||
assert.equal(changed.history.length, 2);
|
||||
assert.equal(changed.history[0]?.values.requiresVpn, false);
|
||||
const switched = await request(`${path}/${first.id}/update`, {
|
||||
values: { ...changed.values, preferred: false },
|
||||
});
|
||||
assert.equal(switched.status, 200);
|
||||
assert.equal(
|
||||
(
|
||||
await request(`${path}/${secondItem.id}/update`, {
|
||||
values: { ...secondItem.values, preferred: true },
|
||||
})
|
||||
).status,
|
||||
200
|
||||
);
|
||||
assert.equal((await request(`${path}?unknown=1`)).status, 400);
|
||||
assert.equal((await request(path, { values }, "http://evil.example")).status, 403);
|
||||
assert.equal((await request(path, { values }, undefined, "invalid-token")).status, 403);
|
||||
const custom = await request("/asset-support/tools", {
|
||||
values: { name: "RustDesk test", active: true },
|
||||
});
|
||||
assert.equal(custom.status, 201);
|
||||
const customTool = SupportToolResponseSchema.parse(await custom.json()).tool;
|
||||
await error(
|
||||
"/asset-support/tools",
|
||||
{ values: { name: "rustdesk TEST", active: true } },
|
||||
409,
|
||||
"TOOL_EXISTS"
|
||||
);
|
||||
await error(
|
||||
`/asset-support/tools/${tool.id}/update`,
|
||||
{ values: { name: "Renamed", active: true } },
|
||||
409,
|
||||
"BUILTIN_PROTECTED"
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await request(`/asset-support/tools/${customTool.id}/update`, {
|
||||
values: { name: customTool.name, active: false },
|
||||
})
|
||||
).status,
|
||||
200
|
||||
);
|
||||
await error(
|
||||
path,
|
||||
{ values: { ...values, toolId: customTool.id, preferred: false } },
|
||||
409,
|
||||
"TOOL_UNAVAILABLE"
|
||||
);
|
||||
const listed = SupportListSchema.parse(await (await request(path)).json());
|
||||
assert.equal(listed.connections.length, 2);
|
||||
assert.equal(listed.connections.filter((item) => item.values.preferred).length, 1);
|
||||
const retired = await request(`/assets/${companyId}/${host.id}/update`, {
|
||||
values: { ...host.values, status: "retired" },
|
||||
});
|
||||
assert.equal(retired.status, 200);
|
||||
await error(
|
||||
`${path}/${first.id}/update`,
|
||||
{ values: { ...values, preferred: false } },
|
||||
409,
|
||||
"ASSET_RETIRED"
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await request(`${path}/${first.id}/update`, {
|
||||
values: { ...values, preferred: false, state: "retired" },
|
||||
})
|
||||
).status,
|
||||
200
|
||||
);
|
||||
assert.equal(
|
||||
(await request(`/assets/${companyId}/${host.id}/update`, { values: host.values })).status,
|
||||
200
|
||||
);
|
||||
}
|
||||
@ -86,3 +86,7 @@ RemoteConnection es AggregateRoot; RemoteConnectionRevision es DomainEntity con
|
||||
## Procesos de personas y plantillas
|
||||
|
||||
ProcessTemplate y PersonProcess son AggregateRoot; sus revisiones y las tareas ProcessTask son DomainEntity con UniqueID v7. ProcessTemplateValues y ProcessValues encapsulan nombre Name, referencias UniqueID y opcionales Maybe; las referencias de tarea tienen también Value Object. Instrucciones de plantilla son valores sin identidad propia; cada tarea de ejecución tiene su UUID, preservado al resolverla. La ejecución conserva la plantilla inicial y su historial. Consultar [Procesos de personas](../procesos-de-personas.md).
|
||||
|
||||
## Asistencia técnica remota
|
||||
|
||||
SupportConnection delimita cada método de asistencia de un activo de una compañía. SupportValues encapsula la referencia UniqueID a herramienta y los valores de configuración; SupportConnectionRevision conserva las instantáneas con actor y fecha. SupportTool es la raíz del catálogo global y utiliza Name. Todas las raíces y revisiones nuevas tienen UUID v7. Consultar [Asistencia técnica remota](../asistencia-tecnica-remota.md).
|
||||
|
||||
34
docs/asistencia-tecnica-remota.md
Normal file
34
docs/asistencia-tecnica-remota.md
Normal file
@ -0,0 +1,34 @@
|
||||
# Asistencia técnica remota
|
||||
|
||||
Cada activo puede tener varios métodos para que un técnico le preste soporte. Se consultan y gestionan desde la ficha del activo, incluida la ficha lateral, y desde Entorno actual del perfil de usuario, para su dispositivo principal.
|
||||
|
||||
## Datos y reglas
|
||||
|
||||
- Herramienta del catálogo global: AnyDesk, Supremo Control, Escritorio remoto y TeamViewer vienen de serie.
|
||||
- Identificador o destino opcional, requisito VPN, estado pendiente de configurar/disponible/retirado y notas opcionales.
|
||||
- Un único método preferido por activo, opcional y solo cuando está disponible. Para cambiarlo se desmarca el anterior antes de marcar el nuevo.
|
||||
- Se permiten varios registros de una herramienta cuando tienen destinos diferentes. Se rechaza repetir una herramienta y destino vigentes, sin distinguir mayúsculas. Los registros retirados conservan historial y no bloquean una nueva alta.
|
||||
- Un activo retirado conserva sus métodos para revisión; solo admite guardar métodos retirados. Retirar el activo no altera ni desconecta los métodos reales.
|
||||
- Cada modificación semántica añade una revisión con UUID, actor y fecha. Guardar sin cambios no duplica el historial. No hay eliminación.
|
||||
- Las herramientas pueden añadirse, editarse y activarse/desactivarse. Los nombres de serie están protegidos. Desactivar impide seleccionarlas en altas y cambios hacia esa herramienta, mantiene las referencias existentes y muestra un aviso de revisión.
|
||||
|
||||
Los formularios son modales. El desplegable de herramienta usa SelectWithAction para el alta rápida, conservando el formulario padre y seleccionando el elemento creado. La ayuda precede a Guardar/Cancelar. La gestión del catálogo también se abre en modal.
|
||||
|
||||
Esta información documenta la asistencia del técnico al activo. No concede autorizaciones de teletrabajo, no establece conexiones y no almacena contraseñas ni claves; las credenciales requieren una solución específica.
|
||||
|
||||
## Persistencia y contratos
|
||||
|
||||
SupportConnection es AggregateRoot con SupportValues y SupportConnectionRevision como DomainEntity; SupportTool es otro AggregateRoot con Name. UUID v7 en nuevas raíces y revisiones; rehydrate preserva identidades e historial.
|
||||
|
||||
Migración explícita e idempotente: support_tools, asset_support, asset_support_revisions y el bloqueo técnico support_catalog_lock. FK compuesta valida la pertenencia del activo a la compañía. FK a herramientas y actores; clave única para el preferido. Las escrituras bloquean la compañía y las altas de herramientas serializan el catálogo.
|
||||
|
||||
API autenticada /api/asset-support:
|
||||
- GET /tools; POST /tools; POST /tools/:id/update.
|
||||
- GET /:companyId/:assetId; POST /:companyId/:assetId.
|
||||
- POST /:companyId/:assetId/:id/update.
|
||||
|
||||
Entradas y salidas estrictas, CSRF y origen aplicados por el servidor. UUID de la compañía y activo validados; los registros ajenos a ese activo no se pueden editar.
|
||||
|
||||
## Verificación
|
||||
|
||||
Pruebas de dominio, mapper, interfaz e integración HTTP/MariaDB: varios métodos, preferido único, duplicados, historial/idempotencia, protección de herramientas de serie, inactividad, retirada de activo, pertenencia, autenticación, CSRF y origen. No se crean datos de prueba en la base de desarrollo.
|
||||
@ -37,3 +37,5 @@ En navegador se comprueban los datos reales del perfil, el filtro de atención,
|
||||
La ficha consulta también la [autorización de teletrabajo](teletrabajo.md), común a los perfiles de la persona y separada de las configuraciones del dispositivo. Muestra vigencia y revisión o ausencia de registro; permite gestionar la autorización y volver al mismo perfil.
|
||||
|
||||
La ficha muestra también [accesos remotos](accesos-remotos.md) abiertos de la persona, incluidos los que parten de otros dispositivos o equipos personales. Los recorridos y avisos aparecen en un bloque independiente de las configuraciones del perfil. Se excluyen cerrados y registros de otras personas/compañías. Gestionar abre la consulta filtrada y Volver conserva el perfil seleccionado.
|
||||
|
||||
La sección Asistencia técnica remota permite consultar y gestionar los métodos de soporte del dispositivo principal mediante modales. Estos métodos pertenecen al activo y se muestran en todos sus perfiles de usuario; no son permisos de teletrabajo de la persona. Consultar [Asistencia técnica remota](asistencia-tecnica-remota.md).
|
||||
|
||||
@ -43,3 +43,5 @@ GET /api/assets/:companyId consulta el inventario. POST /api/assets/:companyId y
|
||||
Las pruebas verifican características opcionales y obligatorias, tipos, fechas, cero y falso, UUID, igualdad semántica, aislamiento por compañía, historial, desactivaciones, rehidratación y protección de las escrituras. La comprobación en navegador incluye alta rápida, cancelación, conservación de fechas y consulta del historial.
|
||||
|
||||
Las [relaciones entre activos](relaciones-entre-activos.md) están disponibles en ambas fichas. Los [puestos de trabajo](puestos-de-trabajo.md) están disponibles como agrupaciones sin anidación. Los perfiles y sus configuraciones están implementados, así como la [autorización de teletrabajo](teletrabajo.md). La conectividad remota, el registro mínimo de equipos personales y los procesos de salida mantienen su orden en el plan.
|
||||
|
||||
La ficha de cada activo, incluida su vista lateral, incorpora [Asistencia técnica remota](asistencia-tecnica-remota.md): varios métodos, catálogo de herramientas, preferido opcional e historial.
|
||||
|
||||
@ -79,10 +79,14 @@ Origen corporativo o personal sin inventario detallado, herramienta libre, desti
|
||||
|
||||
Plantillas globales de alta/cambio/salida, copias de instrucciones por ejecución, seguimiento por persona con tareas UUID, resultados, cierres, historial y revisión de registros actuales. Cambio y salida incorporan revisiones de cuentas, configuraciones, activos asignados y accesos remotos personales. Modales, altas rápidas y protección frente a ediciones concurrentes. Consultar [Procesos de personas](procesos-de-personas.md).
|
||||
|
||||
## Asistencia técnica remota: implementada
|
||||
|
||||
Varios métodos por activo, catálogo global ampliable con herramientas de serie, destino opcional, requisito VPN, estados, preferido opcional e historial. Modales en ficha del activo y Entorno actual. Consultar [Asistencia técnica remota](asistencia-tecnica-remota.md).
|
||||
|
||||
## Siguientes entregas: orden actualizado
|
||||
|
||||
1. Asistencia técnica remota independiente de los accesos de las personas. Vinculación explícita entre un acceso y su VPN previa si resulta necesaria.
|
||||
2. Tickets e intervenciones con técnico, tiempos y elementos afectados.
|
||||
1. Tickets e intervenciones: concretar las peticiones recibidas por email/teléfono, solicitante y elementos afectados; múltiples intervenciones por ticket e intervenciones internas sin ticket.
|
||||
2. Vinculación explícita entre un acceso remoto y su VPN previa, si resulta necesaria.
|
||||
|
||||
Este orden se concreta antes de cada entrega. Las licencias y su relación con cuentas se explorarán posteriormente.
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/api/domain/workstation.test.ts src/api/domain/asset-relationship.test.ts src/web/catalog.test.tsx src/web/inventory.test.tsx"
|
||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/api/domain/workstation.test.ts src/api/domain/asset-relationship.test.ts src/web/catalog.test.tsx src/web/inventory.test.tsx src/api/domain/support.test.ts src/web/support.test.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/rdx-ddd": "workspace:*",
|
||||
|
||||
49
modules/assets/src/api/application/support-input.mapper.ts
Normal file
49
modules/assets/src/api/application/support-input.mapper.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { SupportConnection } from "../domain/support-connection.aggregate";
|
||||
import { SupportError, type SupportInput } from "../domain/support-values";
|
||||
import { SupportValues } from "../domain/value-objects/support-values";
|
||||
|
||||
import type { SupportContext } from "./support.repository";
|
||||
export function mapSupportInput(
|
||||
input: SupportInput,
|
||||
context: SupportContext,
|
||||
previous?: SupportConnection
|
||||
) {
|
||||
const values = SupportValues.create(input);
|
||||
if (!values.isSuccess) {
|
||||
return values;
|
||||
}
|
||||
const tool = context.tools.find((item) => item.id.equals(values.data.toolId));
|
||||
if (!(tool && (tool.active || previous?.values.toolId.equals(tool.id)))) {
|
||||
return Result.fail(new SupportError("TOOL_UNAVAILABLE"));
|
||||
}
|
||||
if (context.assetRetired && input.state !== "retired") {
|
||||
return Result.fail(new SupportError("ASSET_RETIRED"));
|
||||
}
|
||||
if (
|
||||
input.preferred &&
|
||||
context.connections.some(
|
||||
(item) =>
|
||||
item.values.toPrimitive().preferred && item.id.toPrimitive() !== previous?.id.toPrimitive()
|
||||
)
|
||||
) {
|
||||
return Result.fail(new SupportError("PREFERRED_EXISTS"));
|
||||
}
|
||||
const next = values.data.toPrimitive();
|
||||
if (
|
||||
next.state !== "retired" &&
|
||||
context.connections.some((item) => {
|
||||
const other = item.values.toPrimitive();
|
||||
return (
|
||||
item.id.toPrimitive() !== previous?.id.toPrimitive() &&
|
||||
other.state !== "retired" &&
|
||||
other.toolId === next.toolId &&
|
||||
other.destination.toLowerCase() === next.destination.toLowerCase()
|
||||
);
|
||||
})
|
||||
) {
|
||||
return Result.fail(new SupportError("CONNECTION_EXISTS"));
|
||||
}
|
||||
return values;
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
import type { SupportConnectionDTO, SupportToolDTO } from "../../common/support";
|
||||
import type { SupportConnection } from "../domain/support-connection.aggregate";
|
||||
import type { SupportTool } from "../domain/support-tool.aggregate";
|
||||
export function buildSupportTool(tool: SupportTool): SupportToolDTO {
|
||||
return {
|
||||
id: tool.id.toPrimitive(),
|
||||
name: tool.name.toPrimitive(),
|
||||
active: tool.active,
|
||||
builtin: tool.builtin,
|
||||
};
|
||||
}
|
||||
export function buildSupport(
|
||||
connection: SupportConnection,
|
||||
names: ReadonlyMap<string, string>
|
||||
): SupportConnectionDTO {
|
||||
return {
|
||||
id: connection.id.toPrimitive(),
|
||||
companyId: connection.companyId.toPrimitive(),
|
||||
assetId: connection.assetId.toPrimitive(),
|
||||
values: connection.values.toPrimitive(),
|
||||
history: connection.history.map((revision) => ({
|
||||
id: revision.id.toPrimitive(),
|
||||
values: revision.values.toPrimitive(),
|
||||
actorId: revision.actorId.toPrimitive(),
|
||||
actorName: names.get(revision.actorId.toPrimitive()) ?? revision.actorId.toPrimitive(),
|
||||
recordedAt: revision.recordedAt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
26
modules/assets/src/api/application/support.repository.ts
Normal file
26
modules/assets/src/api/application/support.repository.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import type { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { SupportConnection } from "../domain/support-connection.aggregate";
|
||||
import type { SupportTool } from "../domain/support-tool.aggregate";
|
||||
import type { SupportError } from "../domain/support-values";
|
||||
export type SupportContext = Readonly<{
|
||||
connections: readonly SupportConnection[];
|
||||
tools: readonly SupportTool[];
|
||||
assetRetired: boolean;
|
||||
actorNames: ReadonlyMap<string, string>;
|
||||
}>;
|
||||
export interface ISupportRepository {
|
||||
get(company: UniqueID, asset: UniqueID): Promise<Result<SupportContext, SupportError>>;
|
||||
save(
|
||||
company: UniqueID,
|
||||
asset: UniqueID,
|
||||
action: (context: SupportContext) => Result<SupportConnection, SupportError>
|
||||
): Promise<
|
||||
Result<{ connection: SupportConnection; actorNames: ReadonlyMap<string, string> }, SupportError>
|
||||
>;
|
||||
tools(): Promise<readonly SupportTool[]>;
|
||||
saveTool(
|
||||
action: (tools: readonly SupportTool[]) => Result<SupportTool, SupportError>
|
||||
): Promise<Result<SupportTool, SupportError>>;
|
||||
}
|
||||
83
modules/assets/src/api/application/support.use-cases.ts
Normal file
83
modules/assets/src/api/application/support.use-cases.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import { SupportConnection } from "../domain/support-connection.aggregate";
|
||||
import { SupportTool } from "../domain/support-tool.aggregate";
|
||||
import { SupportError, type SupportInput, type SupportToolInput } from "../domain/support-values";
|
||||
|
||||
import type { ISupportRepository } from "./support.repository";
|
||||
import { mapSupportInput } from "./support-input.mapper";
|
||||
export class SupportUseCases {
|
||||
constructor(private readonly repository: ISupportRepository) {}
|
||||
get(companyId: string, assetId: string) {
|
||||
const company = UniqueID.create(companyId);
|
||||
const asset = UniqueID.create(assetId);
|
||||
if (!(company.isSuccess && asset.isSuccess)) {
|
||||
return Promise.resolve(Result.fail(new SupportError("INVALID_REQUEST")));
|
||||
}
|
||||
return this.repository.get(company.data, asset.data);
|
||||
}
|
||||
tools() {
|
||||
return this.repository.tools();
|
||||
}
|
||||
save(
|
||||
companyId: string,
|
||||
assetId: string,
|
||||
id: string | undefined,
|
||||
input: SupportInput,
|
||||
actorId: string
|
||||
) {
|
||||
const company = UniqueID.create(companyId);
|
||||
const asset = UniqueID.create(assetId);
|
||||
const actor = UniqueID.create(actorId);
|
||||
const identity = id ? UniqueID.create(id) : undefined;
|
||||
if (
|
||||
!(company.isSuccess && asset.isSuccess && actor.isSuccess) ||
|
||||
(identity && !identity.isSuccess)
|
||||
) {
|
||||
return Promise.resolve(Result.fail(new SupportError("INVALID_REQUEST")));
|
||||
}
|
||||
return this.repository.save(company.data, asset.data, (context) => {
|
||||
const previous = identity?.isSuccess
|
||||
? context.connections.find((item) => item.id.equals(identity.data))
|
||||
: undefined;
|
||||
if (id && !previous) {
|
||||
return Result.fail(new SupportError("CONNECTION_NOT_FOUND"));
|
||||
}
|
||||
const values = mapSupportInput(input, context, previous);
|
||||
if (!values.isSuccess) {
|
||||
return Result.fail(values.error);
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
return previous
|
||||
? previous.update(values.data, actor.data, now)
|
||||
: SupportConnection.create(company.data, asset.data, values.data, actor.data, now);
|
||||
});
|
||||
}
|
||||
saveTool(id: string | undefined, input: SupportToolInput) {
|
||||
const identity = id ? UniqueID.create(id) : undefined;
|
||||
if (identity && !identity.isSuccess) {
|
||||
return Promise.resolve(Result.fail(new SupportError("INVALID_REQUEST")));
|
||||
}
|
||||
return this.repository.saveTool((tools) => {
|
||||
const previous = identity?.isSuccess
|
||||
? tools.find((item) => item.id.equals(identity.data))
|
||||
: undefined;
|
||||
if (id && !previous) {
|
||||
return Result.fail(new SupportError("TOOL_NOT_FOUND"));
|
||||
}
|
||||
if (
|
||||
tools.some(
|
||||
(item) =>
|
||||
item.id.toPrimitive() !== previous?.id.toPrimitive() &&
|
||||
item.name
|
||||
.toPrimitive()
|
||||
.localeCompare(input.name.trim(), "en", { sensitivity: "base" }) === 0
|
||||
)
|
||||
) {
|
||||
return Result.fail(new SupportError("TOOL_EXISTS"));
|
||||
}
|
||||
return previous ? previous.update(input) : SupportTool.create(input);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
import { DomainEntity, type UniqueID } from "@repo/rdx-ddd";
|
||||
|
||||
import type { SupportValues } from "./value-objects/support-values";
|
||||
export type SupportConnectionRevisionProps = Readonly<{
|
||||
values: SupportValues;
|
||||
actorId: UniqueID;
|
||||
recordedAt: string;
|
||||
}>;
|
||||
export class SupportConnectionRevision extends DomainEntity<SupportConnectionRevisionProps> {
|
||||
static create(props: SupportConnectionRevisionProps) {
|
||||
return new SupportConnectionRevision(Object.freeze({ ...props }));
|
||||
}
|
||||
static rehydrate(props: SupportConnectionRevisionProps, id: UniqueID) {
|
||||
return new SupportConnectionRevision(Object.freeze({ ...props }), id);
|
||||
}
|
||||
get values() {
|
||||
return this.props.values;
|
||||
}
|
||||
get actorId() {
|
||||
return this.props.actorId;
|
||||
}
|
||||
get recordedAt() {
|
||||
return this.props.recordedAt;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,81 @@
|
||||
import { AggregateRoot, type UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { SupportConnectionRevision } from "./support-connection-revision.entity";
|
||||
import { SupportError } from "./support-values";
|
||||
import type { SupportValues } from "./value-objects/support-values";
|
||||
|
||||
type SupportConnectionProps = Readonly<{
|
||||
companyId: UniqueID;
|
||||
assetId: UniqueID;
|
||||
values: SupportValues;
|
||||
history: readonly SupportConnectionRevision[];
|
||||
}>;
|
||||
export class SupportConnection extends AggregateRoot<SupportConnectionProps> {
|
||||
private constructor(props: SupportConnectionProps, id?: UniqueID) {
|
||||
super(Object.freeze({ ...props, history: Object.freeze([...props.history]) }), id);
|
||||
}
|
||||
static create(
|
||||
companyId: UniqueID,
|
||||
assetId: UniqueID,
|
||||
values: SupportValues,
|
||||
actorId: UniqueID,
|
||||
recordedAt: string
|
||||
): Result<SupportConnection, SupportError> {
|
||||
if (!z.iso.datetime().safeParse(recordedAt).success) {
|
||||
return Result.fail(new SupportError("INVALID_REQUEST"));
|
||||
}
|
||||
return Result.ok(
|
||||
new SupportConnection({
|
||||
companyId,
|
||||
assetId,
|
||||
values,
|
||||
history: [SupportConnectionRevision.create({ values, actorId, recordedAt })],
|
||||
})
|
||||
);
|
||||
}
|
||||
static rehydrate(props: SupportConnectionProps, id: UniqueID) {
|
||||
return new SupportConnection(props, id);
|
||||
}
|
||||
get assetId() {
|
||||
return this.props.assetId;
|
||||
}
|
||||
get companyId() {
|
||||
return this.props.companyId;
|
||||
}
|
||||
get values() {
|
||||
return this.props.values;
|
||||
}
|
||||
get history() {
|
||||
return this.props.history;
|
||||
}
|
||||
update(
|
||||
values: SupportValues,
|
||||
actorId: UniqueID,
|
||||
recordedAt: string
|
||||
): Result<SupportConnection, SupportError> {
|
||||
if (this.values.equals(values)) {
|
||||
return Result.ok(this);
|
||||
}
|
||||
if (
|
||||
!z.iso.datetime().safeParse(recordedAt).success ||
|
||||
recordedAt < (this.history.at(-1)?.recordedAt ?? "")
|
||||
) {
|
||||
return Result.fail(new SupportError("INVALID_REQUEST"));
|
||||
}
|
||||
return Result.ok(
|
||||
new SupportConnection(
|
||||
{
|
||||
...this.props,
|
||||
values,
|
||||
history: [
|
||||
...this.history,
|
||||
SupportConnectionRevision.create({ values, actorId, recordedAt }),
|
||||
],
|
||||
},
|
||||
this.id
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
45
modules/assets/src/api/domain/support-tool.aggregate.ts
Normal file
45
modules/assets/src/api/domain/support-tool.aggregate.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { AggregateRoot, Name, type UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import { SupportError, type SupportToolInput, SupportToolInputSchema } from "./support-values";
|
||||
|
||||
type Props = Readonly<{ name: Name; active: boolean; builtin: boolean }>;
|
||||
export class SupportTool extends AggregateRoot<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)
|
||||
);
|
||||
}
|
||||
}
|
||||
22
modules/assets/src/api/domain/support-values.ts
Normal file
22
modules/assets/src/api/domain/support-values.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { z } from "zod/v4";
|
||||
export const SupportValuesSchema = z
|
||||
.strictObject({
|
||||
toolId: z.uuid(),
|
||||
destination: z.string().trim().max(300),
|
||||
requiresVpn: z.boolean(),
|
||||
preferred: z.boolean(),
|
||||
state: z.enum(["pending", "available", "retired"]),
|
||||
notes: z.string().trim().max(2000),
|
||||
})
|
||||
.refine((value) => !value.preferred || value.state === "available");
|
||||
export type SupportInput = z.infer<typeof SupportValuesSchema>;
|
||||
export const SupportToolInputSchema = z.strictObject({
|
||||
name: z.string().trim().min(1).max(100),
|
||||
active: z.boolean(),
|
||||
});
|
||||
export type SupportToolInput = z.infer<typeof SupportToolInputSchema>;
|
||||
export class SupportError extends Error {
|
||||
constructor(public readonly code: string) {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
128
modules/assets/src/api/domain/support.test.ts
Normal file
128
modules/assets/src/api/domain/support.test.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
|
||||
import { mapSupportInput } from "../application/support-input.mapper";
|
||||
import { SupportPersistenceMapper } from "../infrastructure/persistence/support-persistence.mapper";
|
||||
|
||||
import { SupportConnection } from "./support-connection.aggregate";
|
||||
import { SupportTool } from "./support-tool.aggregate";
|
||||
import { SupportValuesSchema } from "./support-values";
|
||||
import { SupportValues } from "./value-objects/support-values";
|
||||
|
||||
const company = UniqueID.generateNewID();
|
||||
const asset = UniqueID.generateNewID();
|
||||
const actor = UniqueID.generateNewID();
|
||||
const toolResult = SupportTool.create({ name: "AnyDesk", active: true }, true);
|
||||
assert.ok(toolResult.isSuccess);
|
||||
const tool = toolResult.data;
|
||||
const input = {
|
||||
toolId: tool.id.toPrimitive(),
|
||||
destination: "123 456",
|
||||
requiresVpn: false,
|
||||
preferred: false,
|
||||
state: "available" as const,
|
||||
notes: "",
|
||||
};
|
||||
function connection() {
|
||||
const values = SupportValues.create(input);
|
||||
assert.ok(values.isSuccess);
|
||||
const result = SupportConnection.create(
|
||||
company,
|
||||
asset,
|
||||
values.data,
|
||||
actor,
|
||||
"2026-10-07T10:00:00.000Z"
|
||||
);
|
||||
assert.ok(result.isSuccess);
|
||||
return result.data;
|
||||
}
|
||||
test("asistencia: UUID v7, historial, cambios semánticos y fechas", () => {
|
||||
const item = connection();
|
||||
assert.equal(item.id.toPrimitive()[14], "7");
|
||||
assert.equal(item.history[0]?.id.toPrimitive()[14], "7");
|
||||
const same = item.update(item.values, actor, "2026-10-07T11:00:00.000Z");
|
||||
assert.ok(same.isSuccess);
|
||||
assert.equal(same.data, item);
|
||||
const values = SupportValues.create({ ...input, requiresVpn: true });
|
||||
assert.ok(values.isSuccess);
|
||||
assert.equal(item.update(values.data, actor, "2026-10-06T10:00:00.000Z").isSuccess, false);
|
||||
const changed = item.update(values.data, actor, "2026-10-07T11:00:00.000Z");
|
||||
assert.ok(changed.isSuccess);
|
||||
assert.ok(changed.data.id.equals(item.id));
|
||||
assert.equal(changed.data.history.length, 2);
|
||||
assert.equal(item.values.toPrimitive().requiresVpn, false);
|
||||
});
|
||||
test("asistencia: preferido disponible y entrada estricta", () => {
|
||||
assert.equal(
|
||||
SupportValuesSchema.safeParse({ ...input, preferred: true, state: "pending" }).success,
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
SupportValuesSchema.safeParse({ ...input, destination: "x".repeat(301) }).success,
|
||||
false
|
||||
);
|
||||
assert.equal(SupportValuesSchema.safeParse({ ...input, password: "secret" }).success, false);
|
||||
assert.equal(SupportValuesSchema.safeParse({ ...input, destination: "" }).success, true);
|
||||
});
|
||||
test("asistencia: varios métodos, duplicados, preferido único y activo retirado", () => {
|
||||
const first = connection();
|
||||
const context = {
|
||||
connections: [first],
|
||||
tools: [tool],
|
||||
assetRetired: false,
|
||||
actorNames: new Map(),
|
||||
};
|
||||
const duplicate = mapSupportInput(input, context);
|
||||
assert.equal(duplicate.isSuccess, false);
|
||||
const distinct = mapSupportInput({ ...input, destination: "otro" }, context);
|
||||
assert.ok(distinct.isSuccess);
|
||||
const preferred = SupportValues.create({ ...input, preferred: true });
|
||||
assert.ok(preferred.isSuccess);
|
||||
const updated = first.update(preferred.data, actor, "2026-10-07T11:00:00.000Z");
|
||||
assert.ok(updated.isSuccess);
|
||||
assert.equal(
|
||||
mapSupportInput(
|
||||
{ ...input, destination: "otro", preferred: true },
|
||||
{ ...context, connections: [updated.data] }
|
||||
).isSuccess,
|
||||
false
|
||||
);
|
||||
assert.equal(mapSupportInput(input, { ...context, assetRetired: true }, first).isSuccess, false);
|
||||
assert.ok(
|
||||
mapSupportInput({ ...input, state: "retired" }, { ...context, assetRetired: true }, first)
|
||||
.isSuccess
|
||||
);
|
||||
});
|
||||
test("herramientas: protección de serie y desactivación sin perder referencias", () => {
|
||||
assert.equal(tool.update({ name: "Otra", active: true }).isSuccess, false);
|
||||
const disabled = tool.update({ name: "AnyDesk", active: false });
|
||||
assert.ok(disabled.isSuccess);
|
||||
const context = {
|
||||
connections: [],
|
||||
tools: [disabled.data],
|
||||
assetRetired: false,
|
||||
actorNames: new Map(),
|
||||
};
|
||||
assert.equal(mapSupportInput(input, context).isSuccess, false);
|
||||
assert.ok(mapSupportInput({ ...input, state: "retired" }, context, connection()).isSuccess);
|
||||
});
|
||||
test("asistencia: mapper bidireccional y rechazo de historial inconsistente", () => {
|
||||
const item = connection();
|
||||
const mapper = new SupportPersistenceMapper();
|
||||
const rows = mapper.toPersistence(item);
|
||||
const read = mapper.toDomain(rows.root, rows.revisions);
|
||||
assert.ok(read.id.equals(item.id));
|
||||
assert.ok(read.values.equals(item.values));
|
||||
assert.throws(() => mapper.toDomain(rows.root, []));
|
||||
assert.throws(() =>
|
||||
mapper.toDomain({ ...rows.root, preferredKey: asset.toPrimitive() }, rows.revisions)
|
||||
);
|
||||
assert.throws(() =>
|
||||
mapper.toDomain(
|
||||
rows.root,
|
||||
rows.revisions.map((row) => ({ ...row, position: 1 }))
|
||||
)
|
||||
);
|
||||
});
|
||||
@ -0,0 +1,37 @@
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import { SupportError, type SupportInput, SupportValuesSchema } from "../support-values";
|
||||
export class SupportValues {
|
||||
private constructor(
|
||||
public readonly toolId: UniqueID,
|
||||
private readonly data: Omit<SupportInput, "toolId">
|
||||
) {
|
||||
Object.freeze(data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
static create(input: SupportInput): Result<SupportValues, SupportError> {
|
||||
const parsed = SupportValuesSchema.safeParse(input);
|
||||
const tool = UniqueID.create(input.toolId);
|
||||
if (!(parsed.success && tool.isSuccess)) {
|
||||
return Result.fail(new SupportError("INVALID_REQUEST"));
|
||||
}
|
||||
const { toolId: _, ...data } = parsed.data;
|
||||
return Result.ok(new SupportValues(tool.data, data));
|
||||
}
|
||||
toPrimitive(): SupportInput {
|
||||
return { ...this.data, toolId: this.toolId.toPrimitive() };
|
||||
}
|
||||
equals(other: SupportValues) {
|
||||
const a = this.data;
|
||||
const b = other.data;
|
||||
return (
|
||||
this.toolId.equals(other.toolId) &&
|
||||
a.destination === b.destination &&
|
||||
a.requiresVpn === b.requiresVpn &&
|
||||
a.preferred === b.preferred &&
|
||||
a.state === b.state &&
|
||||
a.notes === b.notes
|
||||
);
|
||||
}
|
||||
}
|
||||
110
modules/assets/src/api/infrastructure/http/support.routes.ts
Normal file
110
modules/assets/src/api/infrastructure/http/support.routes.ts
Normal file
@ -0,0 +1,110 @@
|
||||
import type { Application, RequestHandler, Response } from "express";
|
||||
import { Router } from "express";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { SupportToolInputSchema, SupportValuesSchema } from "../../../common/support";
|
||||
import type { SupportUseCases } from "../../application/support.use-cases";
|
||||
import { buildSupport, buildSupportTool } from "../../application/support-response.builder";
|
||||
|
||||
function fail(response: Response, code: string) {
|
||||
response
|
||||
.status(code === "INVALID_REQUEST" ? 400 : code.endsWith("NOT_FOUND") ? 404 : 409)
|
||||
.json({ code, requestId: response.getHeader("x-request-id") });
|
||||
}
|
||||
const Params = z.strictObject({ companyId: z.uuid(), assetId: z.uuid() });
|
||||
const Empty = z.strictObject({});
|
||||
export function registerSupportRoutes(
|
||||
app: Application,
|
||||
useCases: SupportUseCases,
|
||||
authenticate: RequestHandler
|
||||
) {
|
||||
const router = Router();
|
||||
router.get("/tools", (request, response, next) => {
|
||||
if (!Empty.safeParse(request.query).success) {
|
||||
fail(response, "INVALID_REQUEST");
|
||||
return;
|
||||
}
|
||||
useCases
|
||||
.tools()
|
||||
.then((tools) => response.json({ tools: tools.map(buildSupportTool) }))
|
||||
.catch(next);
|
||||
});
|
||||
router.get("/:companyId/:assetId", (request, response, next) => {
|
||||
const params = Params.safeParse(request.params);
|
||||
if (!(params.success && Empty.safeParse(request.query).success)) {
|
||||
fail(response, "INVALID_REQUEST");
|
||||
return;
|
||||
}
|
||||
useCases
|
||||
.get(params.data.companyId, params.data.assetId)
|
||||
.then((result) => {
|
||||
if (!result.isSuccess) {
|
||||
fail(response, result.error.code);
|
||||
return;
|
||||
}
|
||||
response.json({
|
||||
connections: result.data.connections.map((item) =>
|
||||
buildSupport(item, result.data.actorNames)
|
||||
),
|
||||
tools: result.data.tools.map(buildSupportTool),
|
||||
});
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
for (const path of ["/tools", "/tools/:id/update"]) {
|
||||
router.post(path, (request, response, next) => {
|
||||
const params = (path.endsWith("update") ? z.strictObject({ id: z.uuid() }) : Empty).safeParse(
|
||||
request.params
|
||||
);
|
||||
const body = z.strictObject({ values: SupportToolInputSchema }).safeParse(request.body);
|
||||
if (!(params.success && body.success && Empty.safeParse(request.query).success)) {
|
||||
fail(response, "INVALID_REQUEST");
|
||||
return;
|
||||
}
|
||||
const id =
|
||||
"id" in params.data && typeof params.data.id === "string" ? params.data.id : undefined;
|
||||
useCases
|
||||
.saveTool(id, body.data.values)
|
||||
.then((result) => {
|
||||
if (!result.isSuccess) {
|
||||
fail(response, result.error.code);
|
||||
return;
|
||||
}
|
||||
response.status(id ? 200 : 201).json({ tool: buildSupportTool(result.data) });
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
}
|
||||
for (const path of ["/:companyId/:assetId", "/:companyId/:assetId/:id/update"]) {
|
||||
router.post(path, (request, response, next) => {
|
||||
const params = (path.endsWith("update") ? Params.extend({ id: z.uuid() }) : Params).safeParse(
|
||||
request.params
|
||||
);
|
||||
const body = z.strictObject({ values: SupportValuesSchema }).safeParse(request.body);
|
||||
const actor = z
|
||||
.object({ userId: z.uuid() })
|
||||
.safeParse("session" in request ? request.session : undefined);
|
||||
if (
|
||||
!(params.success && body.success && actor.success && Empty.safeParse(request.query).success)
|
||||
) {
|
||||
fail(response, "INVALID_REQUEST");
|
||||
return;
|
||||
}
|
||||
const id =
|
||||
"id" in params.data && typeof params.data.id === "string" ? params.data.id : undefined;
|
||||
useCases
|
||||
.save(params.data.companyId, params.data.assetId, id, body.data.values, actor.data.userId)
|
||||
.then((result) => {
|
||||
if (!result.isSuccess) {
|
||||
fail(response, result.error.code);
|
||||
return;
|
||||
}
|
||||
response
|
||||
.status(id ? 200 : 201)
|
||||
.json({ connection: buildSupport(result.data.connection, result.data.actorNames) });
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
}
|
||||
app.use("/api/asset-support", authenticate, router);
|
||||
}
|
||||
@ -0,0 +1,130 @@
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import { QueryTypes, type Sequelize, type Transaction } from "sequelize";
|
||||
|
||||
import type { ISupportRepository, SupportContext } from "../../application/support.repository";
|
||||
import { AssetInputSchema } from "../../domain/asset-values";
|
||||
import type { SupportConnection } from "../../domain/support-connection.aggregate";
|
||||
import type { SupportTool } from "../../domain/support-tool.aggregate";
|
||||
import { SupportError } from "../../domain/support-values";
|
||||
|
||||
import { SupportPersistenceMapper } from "./support-persistence.mapper";
|
||||
export class SequelizeSupportRepository implements ISupportRepository {
|
||||
private readonly mapper = new SupportPersistenceMapper();
|
||||
constructor(private readonly database: Sequelize) {}
|
||||
private async readTools(transaction?: Transaction) {
|
||||
const rows = await this.database.query<Record<string, unknown>>(
|
||||
"SELECT id,name,active,builtin FROM support_tools ORDER BY name",
|
||||
{ type: QueryTypes.SELECT, ...(transaction ? { transaction } : {}) }
|
||||
);
|
||||
return rows.map((row) => this.mapper.toolToDomain(row));
|
||||
}
|
||||
tools() {
|
||||
return this.readTools();
|
||||
}
|
||||
get(company: UniqueID, asset: UniqueID) {
|
||||
return this.database.transaction((transaction) => this.read(company, asset, transaction));
|
||||
}
|
||||
private async read(
|
||||
company: UniqueID,
|
||||
asset: UniqueID,
|
||||
transaction: Transaction
|
||||
): Promise<Result<SupportContext, SupportError>> {
|
||||
const replacements = { companyId: company.toPrimitive(), assetId: asset.toPrimitive() };
|
||||
const assets = await this.database.query<{ values: unknown }>(
|
||||
"SELECT `values` FROM assets WHERE companyId=:companyId AND id=:assetId",
|
||||
{ replacements, transaction, type: QueryTypes.SELECT }
|
||||
);
|
||||
if (!assets[0]) {
|
||||
return Result.fail(new SupportError("ASSET_NOT_FOUND"));
|
||||
}
|
||||
const assetValues =
|
||||
typeof assets[0].values === "string" ? JSON.parse(assets[0].values) : assets[0].values;
|
||||
const assetInput = AssetInputSchema.parse(assetValues);
|
||||
const roots = await this.database.query<Record<string, unknown>>(
|
||||
"SELECT id,companyId,assetId,toolId,`values`,preferredKey FROM asset_support WHERE companyId=:companyId AND assetId=:assetId",
|
||||
{ replacements, transaction, type: QueryTypes.SELECT }
|
||||
);
|
||||
const revisions = await this.database.query<Record<string, unknown>>(
|
||||
"SELECT r.id,r.connectionId,r.actorId,r.toolId,r.`values`,r.recordedAt,r.position FROM asset_support_revisions r JOIN asset_support c ON c.id=r.connectionId WHERE c.companyId=:companyId AND c.assetId=:assetId",
|
||||
{ replacements, transaction, type: QueryTypes.SELECT }
|
||||
);
|
||||
const users = await this.database.query<{ id: string; name: string }>(
|
||||
"SELECT id,name FROM users",
|
||||
{ transaction, type: QueryTypes.SELECT }
|
||||
);
|
||||
return Result.ok({
|
||||
assetRetired: assetInput.status === "retired",
|
||||
tools: await this.readTools(transaction),
|
||||
connections: roots.map((root) =>
|
||||
this.mapper.toDomain(
|
||||
root,
|
||||
revisions.filter((row) => row.connectionId === root.id)
|
||||
)
|
||||
),
|
||||
actorNames: new Map(users.map((item) => [item.id, item.name])),
|
||||
});
|
||||
}
|
||||
async save(
|
||||
company: UniqueID,
|
||||
asset: UniqueID,
|
||||
action: (context: SupportContext) => Result<SupportConnection, SupportError>
|
||||
) {
|
||||
return this.database.transaction(async (transaction) => {
|
||||
await this.database.query("SELECT id FROM companies WHERE id=:id FOR UPDATE", {
|
||||
replacements: { id: company.toPrimitive() },
|
||||
transaction,
|
||||
type: QueryTypes.SELECT,
|
||||
});
|
||||
const context = await this.read(company, asset, transaction);
|
||||
if (!context.isSuccess) {
|
||||
return Result.fail(context.error);
|
||||
}
|
||||
const result = action(context.data);
|
||||
if (!result.isSuccess) {
|
||||
return Result.fail(result.error);
|
||||
}
|
||||
const data = this.mapper.toPersistence(result.data);
|
||||
await this.database.query(
|
||||
context.data.connections.some((item) => item.id.equals(result.data.id))
|
||||
? "UPDATE asset_support SET toolId=:toolId,`values`=:values,preferredKey=:preferredKey WHERE id=:id"
|
||||
: "INSERT INTO asset_support (id,companyId,assetId,toolId,`values`,preferredKey) VALUES (:id,:companyId,:assetId,:toolId,:values,:preferredKey)",
|
||||
{ replacements: data.root, transaction }
|
||||
);
|
||||
const existing = new Set(
|
||||
context.data.connections
|
||||
.find((item) => item.id.equals(result.data.id))
|
||||
?.history.map((item) => item.id.toPrimitive()) ?? []
|
||||
);
|
||||
for (const row of data.revisions) {
|
||||
if (!existing.has(row.id)) {
|
||||
await this.database.query(
|
||||
"INSERT INTO asset_support_revisions (id,connectionId,actorId,toolId,`values`,recordedAt,position) VALUES (:id,:connectionId,:actorId,:toolId,:values,:recordedAt,:position)",
|
||||
{ replacements: row, transaction }
|
||||
);
|
||||
}
|
||||
}
|
||||
return Result.ok({ connection: result.data, actorNames: context.data.actorNames });
|
||||
});
|
||||
}
|
||||
saveTool(action: (tools: readonly SupportTool[]) => Result<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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,73 @@
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { DataTypes, QueryTypes, type Sequelize } from "sequelize";
|
||||
export async function migrateSupport(database: Sequelize) {
|
||||
const query = database.getQueryInterface();
|
||||
const tables = await database.query<{ name: string }>(
|
||||
"SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()",
|
||||
{ type: QueryTypes.SELECT }
|
||||
);
|
||||
const fk = (model: string) => ({
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: { model, key: "id" },
|
||||
onDelete: "RESTRICT",
|
||||
});
|
||||
const definitions = [
|
||||
["support_catalog_lock", { id: { type: DataTypes.INTEGER, primaryKey: true } }],
|
||||
[
|
||||
"support_tools",
|
||||
{
|
||||
id: { type: DataTypes.UUID, primaryKey: true },
|
||||
name: { type: DataTypes.STRING(100), allowNull: false, unique: true },
|
||||
active: { type: DataTypes.BOOLEAN, allowNull: false },
|
||||
builtin: { type: DataTypes.BOOLEAN, allowNull: false },
|
||||
},
|
||||
],
|
||||
[
|
||||
"asset_support",
|
||||
{
|
||||
id: { type: DataTypes.UUID, primaryKey: true },
|
||||
companyId: fk("companies"),
|
||||
assetId: fk("assets"),
|
||||
toolId: fk("support_tools"),
|
||||
values: { type: DataTypes.JSON, allowNull: false },
|
||||
preferredKey: { type: DataTypes.UUID, allowNull: true, unique: true },
|
||||
},
|
||||
],
|
||||
[
|
||||
"asset_support_revisions",
|
||||
{
|
||||
id: { type: DataTypes.UUID, primaryKey: true },
|
||||
connectionId: fk("asset_support"),
|
||||
actorId: fk("users"),
|
||||
toolId: fk("support_tools"),
|
||||
values: { type: DataTypes.JSON, allowNull: false },
|
||||
recordedAt: { type: DataTypes.STRING(30), allowNull: false },
|
||||
position: { type: DataTypes.INTEGER, allowNull: false },
|
||||
},
|
||||
],
|
||||
] as const;
|
||||
for (const [table, columns] of definitions) {
|
||||
if (!tables.some((item) => item.name === table)) {
|
||||
await query.createTable(table, columns);
|
||||
}
|
||||
}
|
||||
const constraints = await database.query<{ name: string }>(
|
||||
"SELECT CONSTRAINT_NAME AS name FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='asset_support'",
|
||||
{ type: QueryTypes.SELECT }
|
||||
);
|
||||
if (!constraints.some((item) => item.name === "support_asset_company")) {
|
||||
await database.query(
|
||||
"ALTER TABLE asset_support ADD CONSTRAINT support_asset_company FOREIGN KEY (companyId,assetId) REFERENCES assets(companyId,id)"
|
||||
);
|
||||
}
|
||||
await database.query("INSERT IGNORE INTO support_catalog_lock (id) VALUES (1)");
|
||||
for (const name of ["AnyDesk", "Supremo Control", "Escritorio remoto", "TeamViewer"]) {
|
||||
await database.query(
|
||||
"INSERT IGNORE INTO support_tools (id,name,active,builtin) VALUES (:id,:name,1,1)",
|
||||
{
|
||||
replacements: { id: UniqueID.generateNewID().toPrimitive(), name },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,140 @@
|
||||
import { Name, UniqueID } from "@repo/rdx-ddd";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { SupportConnection } from "../../domain/support-connection.aggregate";
|
||||
import { SupportConnectionRevision } from "../../domain/support-connection-revision.entity";
|
||||
import { SupportTool } from "../../domain/support-tool.aggregate";
|
||||
import { SupportValuesSchema } from "../../domain/support-values";
|
||||
import { SupportValues } from "../../domain/value-objects/support-values";
|
||||
|
||||
const RootSchema = z.strictObject({
|
||||
id: z.uuid(),
|
||||
companyId: z.uuid(),
|
||||
assetId: z.uuid(),
|
||||
toolId: z.uuid(),
|
||||
values: SupportValuesSchema,
|
||||
preferredKey: z.uuid().nullable(),
|
||||
});
|
||||
const RevisionSchema = z.strictObject({
|
||||
id: z.uuid(),
|
||||
connectionId: z.uuid(),
|
||||
actorId: z.uuid(),
|
||||
toolId: z.uuid(),
|
||||
values: SupportValuesSchema,
|
||||
recordedAt: z.iso.datetime(),
|
||||
position: z.number().int().nonnegative(),
|
||||
});
|
||||
export function supportIdentity(value: string) {
|
||||
const id = UniqueID.create(value);
|
||||
if (!id.isSuccess) {
|
||||
throw new Error("Identidad de asistencia inconsistente.");
|
||||
}
|
||||
return id.data;
|
||||
}
|
||||
function json(value: unknown): unknown {
|
||||
return typeof value === "string" ? JSON.parse(value) : value;
|
||||
}
|
||||
function values(value: unknown) {
|
||||
const parsed = SupportValuesSchema.parse(json(value));
|
||||
const result = SupportValues.create(parsed);
|
||||
if (!result.isSuccess) {
|
||||
throw new Error("Valores de asistencia inconsistentes.");
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
export class SupportPersistenceMapper {
|
||||
toDomain(root: Record<string, unknown>, rows: readonly Record<string, unknown>[]) {
|
||||
const current = RootSchema.parse({ ...root, values: json(root.values) });
|
||||
const history = rows
|
||||
.map((row) => RevisionSchema.parse({ ...row, values: json(row.values) }))
|
||||
.sort((a, b) => a.position - b.position);
|
||||
if (
|
||||
!history.length ||
|
||||
new Set(history.map((item) => item.id)).size !== history.length ||
|
||||
history.some(
|
||||
(item, index) =>
|
||||
item.connectionId !== current.id ||
|
||||
item.position !== index ||
|
||||
item.toolId !== item.values.toolId ||
|
||||
(index > 0 && item.recordedAt < (history[index - 1]?.recordedAt ?? ""))
|
||||
)
|
||||
) {
|
||||
throw new Error("Historial de asistencia inconsistente.");
|
||||
}
|
||||
const currentValues = values(current.values);
|
||||
const revisions = history.map((item) =>
|
||||
SupportConnectionRevision.rehydrate(
|
||||
{
|
||||
values: values(item.values),
|
||||
actorId: supportIdentity(item.actorId),
|
||||
recordedAt: item.recordedAt,
|
||||
},
|
||||
supportIdentity(item.id)
|
||||
)
|
||||
);
|
||||
if (
|
||||
current.toolId !== current.values.toolId ||
|
||||
current.preferredKey !== (current.values.preferred ? current.assetId : null) ||
|
||||
!revisions.at(-1)?.values.equals(currentValues)
|
||||
) {
|
||||
throw new Error("Asistencia inconsistente.");
|
||||
}
|
||||
return SupportConnection.rehydrate(
|
||||
{
|
||||
companyId: supportIdentity(current.companyId),
|
||||
assetId: supportIdentity(current.assetId),
|
||||
values: currentValues,
|
||||
history: revisions,
|
||||
},
|
||||
supportIdentity(current.id)
|
||||
);
|
||||
}
|
||||
toPersistence(connection: SupportConnection) {
|
||||
const input = connection.values.toPrimitive();
|
||||
return {
|
||||
root: {
|
||||
id: connection.id.toPrimitive(),
|
||||
companyId: connection.companyId.toPrimitive(),
|
||||
assetId: connection.assetId.toPrimitive(),
|
||||
toolId: input.toolId,
|
||||
values: JSON.stringify(input),
|
||||
preferredKey: input.preferred ? connection.assetId.toPrimitive() : null,
|
||||
},
|
||||
revisions: connection.history.map((item, position) => ({
|
||||
id: item.id.toPrimitive(),
|
||||
connectionId: connection.id.toPrimitive(),
|
||||
actorId: item.actorId.toPrimitive(),
|
||||
toolId: item.values.toolId.toPrimitive(),
|
||||
values: JSON.stringify(item.values.toPrimitive()),
|
||||
recordedAt: item.recordedAt,
|
||||
position,
|
||||
})),
|
||||
};
|
||||
}
|
||||
toolToDomain(row: Record<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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -6,11 +6,13 @@ import type { Sequelize } from "sequelize";
|
||||
import { AssetUseCases } from "./application/asset.use-cases";
|
||||
import { AssetCatalogUseCases } from "./application/asset-catalog.use-cases";
|
||||
import { AssetRelationshipUseCases } from "./application/asset-relationship.use-cases";
|
||||
import { SupportUseCases } from "./application/support.use-cases";
|
||||
import { WorkstationUseCases } from "./application/workstation.use-cases";
|
||||
import { seedAssetCatalog } from "./domain/asset-catalog-seed";
|
||||
import { registerAssetRoutes } from "./infrastructure/http/asset.routes";
|
||||
import { registerAssetCatalogRoutes } from "./infrastructure/http/asset-catalog.routes";
|
||||
import { registerRelationshipRoutes } from "./infrastructure/http/asset-relationship.routes";
|
||||
import { registerSupportRoutes } from "./infrastructure/http/support.routes";
|
||||
import { registerWorkstationRoutes } from "./infrastructure/http/workstation.routes";
|
||||
import { migrateInventory } from "./infrastructure/persistence/asset.model";
|
||||
import {
|
||||
@ -21,6 +23,8 @@ import { AssetCatalogPersistenceMapper } from "./infrastructure/persistence/asse
|
||||
import { migrateRelationships } from "./infrastructure/persistence/asset-relationship.model";
|
||||
import { SequelizeAssetRepository } from "./infrastructure/persistence/sequelize-asset.repository";
|
||||
import { SequelizeAssetCatalogRepository } from "./infrastructure/persistence/sequelize-asset-catalog.repository";
|
||||
import { SequelizeSupportRepository } from "./infrastructure/persistence/sequelize-support.repository";
|
||||
import { migrateSupport } from "./infrastructure/persistence/support-migration";
|
||||
import { migrateWorkstations } from "./infrastructure/persistence/workstation.model";
|
||||
export async function createAssetModule(database: Sequelize, authenticate: RequestHandler) {
|
||||
await migrateAssetCatalog(database);
|
||||
@ -41,6 +45,8 @@ export async function createAssetModule(database: Sequelize, authenticate: Reque
|
||||
await migrateInventory(database);
|
||||
await migrateRelationships(database);
|
||||
await migrateWorkstations(database);
|
||||
await migrateSupport(database);
|
||||
const support = new SupportUseCases(new SequelizeSupportRepository(database));
|
||||
const assetRepository = new SequelizeAssetRepository(database);
|
||||
const inventory = new AssetUseCases(assetRepository);
|
||||
const workstations = new WorkstationUseCases(assetRepository);
|
||||
@ -68,6 +74,7 @@ export async function createAssetModule(database: Sequelize, authenticate: Reque
|
||||
registerAssetRoutes(app, inventory, authenticate);
|
||||
registerRelationshipRoutes(app, relationships, authenticate);
|
||||
registerWorkstationRoutes(app, workstations, authenticate);
|
||||
registerSupportRoutes(app, support, authenticate);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -29,4 +29,5 @@ export type SaveDefinitionResponseDTO = z.infer<typeof SaveDefinitionResponseSch
|
||||
|
||||
export * from "./inventory";
|
||||
export * from "./relationships";
|
||||
export * from "./support";
|
||||
export * from "./workstations";
|
||||
|
||||
38
modules/assets/src/common/support.ts
Normal file
38
modules/assets/src/common/support.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { SupportToolInputSchema, SupportValuesSchema } from "../api/domain/support-values";
|
||||
|
||||
export type { SupportInput, SupportToolInput } from "../api/domain/support-values";
|
||||
|
||||
export { SupportToolInputSchema, SupportValuesSchema };
|
||||
export const SupportToolSchema = z.strictObject({
|
||||
id: z.uuid(),
|
||||
name: z.string(),
|
||||
active: z.boolean(),
|
||||
builtin: z.boolean(),
|
||||
});
|
||||
export const SupportConnectionSchema = z.strictObject({
|
||||
id: z.uuid(),
|
||||
companyId: z.uuid(),
|
||||
assetId: z.uuid(),
|
||||
values: SupportValuesSchema,
|
||||
history: z.array(
|
||||
z.strictObject({
|
||||
id: z.uuid(),
|
||||
values: SupportValuesSchema,
|
||||
actorId: z.uuid(),
|
||||
actorName: z.string(),
|
||||
recordedAt: z.iso.datetime(),
|
||||
})
|
||||
),
|
||||
});
|
||||
export const SupportListSchema = z.strictObject({
|
||||
connections: z.array(SupportConnectionSchema),
|
||||
tools: z.array(SupportToolSchema),
|
||||
});
|
||||
export const SupportResponseSchema = z.strictObject({ connection: SupportConnectionSchema });
|
||||
export const SupportToolsSchema = z.strictObject({ tools: z.array(SupportToolSchema) });
|
||||
export const SupportToolResponseSchema = z.strictObject({ tool: SupportToolSchema });
|
||||
export type SupportConnectionDTO = z.infer<typeof SupportConnectionSchema>;
|
||||
export type SupportToolDTO = z.infer<typeof SupportToolSchema>;
|
||||
export type SupportListDTO = z.infer<typeof SupportListSchema>;
|
||||
198
modules/assets/src/web/asset-support.tsx
Normal file
198
modules/assets/src/web/asset-support.tsx
Normal file
@ -0,0 +1,198 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { SupportConnectionDTO, SupportToolDTO } from "../common/support";
|
||||
|
||||
import { supportApi, supportKeys } from "./support-api";
|
||||
import { SupportForm } from "./support-form";
|
||||
import { SupportTools } from "./support-tools";
|
||||
export function AssetSupport({
|
||||
companyId,
|
||||
assetId,
|
||||
csrfToken,
|
||||
retired = false,
|
||||
}: {
|
||||
companyId: string;
|
||||
assetId: string;
|
||||
csrfToken: string;
|
||||
retired?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("assets");
|
||||
const query = useQuery({
|
||||
queryKey: supportKeys.asset(companyId, assetId),
|
||||
queryFn: () => supportApi.get(companyId, assetId),
|
||||
});
|
||||
const [open, setOpen] = useState(false);
|
||||
const [edit, setEdit] = useState<SupportConnectionDTO | undefined>();
|
||||
const [toolsOpen, setToolsOpen] = useState(false);
|
||||
const [showRetired, setShowRetired] = useState(false);
|
||||
const [history, setHistory] = useState<SupportConnectionDTO | undefined>();
|
||||
const items =
|
||||
query.data?.connections.filter((item) => showRetired || item.values.state !== "retired") ?? [];
|
||||
const tools = query.data?.tools ?? [];
|
||||
return (
|
||||
<section className="space-y-3 rounded-md border p-4">
|
||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="font-semibold">{t("support.title")}</h3>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => setToolsOpen(true)} size="sm" variant="outline">
|
||||
{t("support.tools")}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!query.data || retired}
|
||||
onClick={() => {
|
||||
setEdit(undefined);
|
||||
setOpen(true);
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
{t("support.new")}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
{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>
|
||||
)}
|
||||
{query.data && (
|
||||
<>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
checked={showRetired}
|
||||
onChange={(event) => setShowRetired(event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
{t("support.showRetired")}
|
||||
</label>
|
||||
{!items.length && <p className="text-sm text-muted-foreground">{t("support.empty")}</p>}
|
||||
<ul className="divide-y">
|
||||
{[...items]
|
||||
.sort((a, b) => Number(b.values.preferred) - Number(a.values.preferred))
|
||||
.map((item) => (
|
||||
<li className="space-y-2 py-3" key={item.id}>
|
||||
<SupportSummary tools={tools} values={item.values} />
|
||||
{item.values.state !== "retired" &&
|
||||
(retired || !tools.find((tool) => tool.id === item.values.toolId)?.active) && (
|
||||
<p className="text-sm text-amber-700">{t("support.review")}</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEdit(item);
|
||||
setOpen(true);
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
<Button onClick={() => setHistory(item)} size="sm" variant="outline">
|
||||
{t("support.history")}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
onOpenChange={setOpen}
|
||||
open={open}
|
||||
title={t(edit ? "support.edit" : "support.new")}
|
||||
>
|
||||
<SupportForm
|
||||
assetId={assetId}
|
||||
companyId={companyId}
|
||||
csrfToken={csrfToken}
|
||||
key={edit?.id ?? "new"}
|
||||
tools={tools}
|
||||
{...(edit ? { connection: edit } : {})}
|
||||
onSaved={() => setOpen(false)}
|
||||
/>
|
||||
</FormDialog>
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
onOpenChange={setToolsOpen}
|
||||
open={toolsOpen}
|
||||
title={t("support.tools")}
|
||||
>
|
||||
<SupportTools csrfToken={csrfToken} />
|
||||
</FormDialog>
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
onOpenChange={(value) => {
|
||||
if (!value) {
|
||||
setHistory(undefined);
|
||||
}
|
||||
}}
|
||||
open={Boolean(history)}
|
||||
title={t("support.history")}
|
||||
>
|
||||
{history && <SupportHistory connection={history} tools={tools} />}
|
||||
</FormDialog>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
export function SupportSummary({
|
||||
values,
|
||||
tools,
|
||||
}: {
|
||||
values: SupportConnectionDTO["values"];
|
||||
tools: readonly SupportToolDTO[];
|
||||
}) {
|
||||
const { t } = useTranslation("assets");
|
||||
return (
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="font-medium">
|
||||
{tools.find((tool) => tool.id === values.toolId)?.name ?? values.toolId} ·{" "}
|
||||
{t(`support.${values.state}`)}
|
||||
{values.preferred ? ` · ${t("support.preferred")}` : ""}
|
||||
</p>
|
||||
{values.destination && <p className="break-words">{values.destination}</p>}
|
||||
<p>
|
||||
{t("support.requiresVpn")}: {t(values.requiresVpn ? "yes" : "no")}
|
||||
</p>
|
||||
{values.notes && <p className="whitespace-pre-wrap text-muted-foreground">{values.notes}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function SupportHistory({
|
||||
connection,
|
||||
tools,
|
||||
}: {
|
||||
connection: SupportConnectionDTO;
|
||||
tools: readonly SupportToolDTO[];
|
||||
}) {
|
||||
const { i18n } = useTranslation("assets");
|
||||
return (
|
||||
<ol className="space-y-3">
|
||||
{[...connection.history].reverse().map((revision) => (
|
||||
<li className="space-y-2 rounded-md border p-3" key={revision.id}>
|
||||
<p className="text-sm">
|
||||
{new Intl.DateTimeFormat(i18n.language, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(revision.recordedAt))}{" "}
|
||||
· {revision.actorName}
|
||||
</p>
|
||||
<SupportSummary tools={tools} values={revision.values} />
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,9 @@
|
||||
import { inventoryEnglish, inventorySpanish } from "./inventory-i18n";
|
||||
import { relationshipsEnglish, relationshipsSpanish } from "./relationship-i18n";
|
||||
import { supportEnglish, supportSpanish } from "./support-i18n";
|
||||
import { workstationsEnglish, workstationsSpanish } from "./workstation-i18n";
|
||||
export const assetsSpanish = {
|
||||
support: supportSpanish,
|
||||
...inventorySpanish,
|
||||
...workstationsSpanish,
|
||||
...relationshipsSpanish,
|
||||
@ -106,6 +108,7 @@ export const assetsSpanish = {
|
||||
},
|
||||
};
|
||||
export const assetsEnglish = {
|
||||
support: supportEnglish,
|
||||
...inventoryEnglish,
|
||||
...workstationsEnglish,
|
||||
...relationshipsEnglish,
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
export { AssetForm } from "./asset-form";
|
||||
export { AssetSupport } from "./asset-support";
|
||||
export { catalogApi, catalogKeys } from "./catalog-api";
|
||||
export { AssetCatalogPage } from "./catalog-page";
|
||||
export { assetsEnglish, assetsSpanish } from "./i18n";
|
||||
|
||||
@ -12,6 +12,7 @@ import { AssetDetail } from "./asset-detail";
|
||||
import type { InventoryLookups } from "./asset-form";
|
||||
import { AssetForm, InventoryFeedback } from "./asset-form";
|
||||
import { AssetRelationships } from "./asset-relationships";
|
||||
import { AssetSupport } from "./asset-support";
|
||||
import { catalogApi, catalogKeys } from "./catalog-api";
|
||||
import { CatalogError } from "./catalog-form";
|
||||
import { inventoryApi, inventoryKeys } from "./inventory-api";
|
||||
@ -209,6 +210,12 @@ export function AssetInventoryPage({
|
||||
>
|
||||
<AssetDetail asset={viewed} lookups={lookups}>
|
||||
<AssetRelationships assetId={viewed.id} companyId={companyId} csrfToken={csrfToken} />
|
||||
<AssetSupport
|
||||
assetId={viewed.id}
|
||||
companyId={companyId}
|
||||
csrfToken={csrfToken}
|
||||
retired={viewed.values.status === "retired"}
|
||||
/>
|
||||
</AssetDetail>
|
||||
</FormDialog>
|
||||
)}
|
||||
@ -255,6 +262,12 @@ function AssetSelectionPanel({
|
||||
</header>
|
||||
<AssetDetail asset={asset} compact lookups={lookups}>
|
||||
<AssetRelationships assetId={asset.id} companyId={companyId} csrfToken={csrfToken} />
|
||||
<AssetSupport
|
||||
assetId={asset.id}
|
||||
companyId={companyId}
|
||||
csrfToken={csrfToken}
|
||||
retired={asset.values.status === "retired"}
|
||||
/>
|
||||
</AssetDetail>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={() => onView(asset.id)} variant="outline">
|
||||
|
||||
54
modules/assets/src/web/support-api.ts
Normal file
54
modules/assets/src/web/support-api.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
type SupportInput,
|
||||
SupportListSchema,
|
||||
SupportResponseSchema,
|
||||
type SupportToolInput,
|
||||
SupportToolResponseSchema,
|
||||
SupportToolsSchema,
|
||||
} from "../common/support";
|
||||
export const supportKeys = {
|
||||
asset: (companyId: string, assetId: string) => ["asset-support", companyId, assetId],
|
||||
tools: ["support-tools"],
|
||||
};
|
||||
export class SupportApiError extends Error {
|
||||
constructor(public readonly code: string) {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
async function request(path: string, values?: unknown, token?: string) {
|
||||
const response = await fetch(`/api/asset-support/${path}`, {
|
||||
method: values === undefined ? "GET" : "POST",
|
||||
credentials: "same-origin",
|
||||
headers:
|
||||
values === undefined
|
||||
? {}
|
||||
: { "content-type": "application/json", "x-csrf-token": token ?? "" },
|
||||
...(values === undefined ? {} : { body: JSON.stringify({ values }) }),
|
||||
});
|
||||
const data: unknown = await response.json();
|
||||
if (!response.ok) {
|
||||
const error = z.object({ code: z.string() }).safeParse(data);
|
||||
throw new SupportApiError(error.success ? error.data.code : "INTERNAL_ERROR");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
export const supportApi = {
|
||||
async get(companyId: string, assetId: string) {
|
||||
return SupportListSchema.parse(await request(`${companyId}/${assetId}`));
|
||||
},
|
||||
async tools() {
|
||||
return SupportToolsSchema.parse(await request("tools"));
|
||||
},
|
||||
async save(companyId: string, assetId: string, values: SupportInput, token: string, id?: string) {
|
||||
return SupportResponseSchema.parse(
|
||||
await request(`${companyId}/${assetId}${id ? `/${id}/update` : ""}`, values, token)
|
||||
);
|
||||
},
|
||||
async saveTool(values: SupportToolInput, token: string, id?: string) {
|
||||
return SupportToolResponseSchema.parse(
|
||||
await request(`tools${id ? `/${id}/update` : ""}`, values, token)
|
||||
);
|
||||
},
|
||||
};
|
||||
154
modules/assets/src/web/support-form.tsx
Normal file
154
modules/assets/src/web/support-form.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { FormActions, FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useId, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
type SupportConnectionDTO,
|
||||
type SupportInput,
|
||||
type SupportToolDTO,
|
||||
SupportValuesSchema,
|
||||
} from "../common/support";
|
||||
|
||||
import { supportApi, supportKeys } from "./support-api";
|
||||
import { SupportFeedback, SupportHelp } from "./support-help";
|
||||
import { SupportToolForm } from "./support-tool-form";
|
||||
export function SupportForm({
|
||||
companyId,
|
||||
assetId,
|
||||
csrfToken,
|
||||
tools,
|
||||
connection,
|
||||
onSaved,
|
||||
}: {
|
||||
companyId: string;
|
||||
assetId: string;
|
||||
csrfToken: string;
|
||||
tools: readonly SupportToolDTO[];
|
||||
connection?: SupportConnectionDTO;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("assets");
|
||||
const prefix = useId();
|
||||
const client = useQueryClient();
|
||||
const [quick, setQuick] = useState(false);
|
||||
const form = useForm<SupportInput>({
|
||||
resolver: zodResolver(SupportValuesSchema),
|
||||
defaultValues: connection?.values ?? {
|
||||
toolId: "",
|
||||
destination: "",
|
||||
requiresVpn: false,
|
||||
preferred: false,
|
||||
state: "pending",
|
||||
notes: "",
|
||||
},
|
||||
});
|
||||
const values = form.watch();
|
||||
const mutation = useMutation({
|
||||
mutationFn: (input: SupportInput) =>
|
||||
supportApi.save(companyId, assetId, input, csrfToken, connection?.id),
|
||||
onSuccess: async () => {
|
||||
await client.invalidateQueries({ queryKey: supportKeys.asset(companyId, assetId) });
|
||||
onSaved();
|
||||
},
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<form className="space-y-5" onSubmit={form.handleSubmit((input) => mutation.mutate(input))}>
|
||||
<fieldset className="space-y-4" disabled={mutation.isPending}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={prefix}>{t("support.tool")}</Label>
|
||||
<SelectWithAction
|
||||
action={{ label: t("support.newTool"), onClick: () => setQuick(true) }}
|
||||
id={prefix}
|
||||
label={t("support.tool")}
|
||||
onValueChange={(id) => form.setValue("toolId", id)}
|
||||
options={tools
|
||||
.filter((item) => item.active || item.id === connection?.values.toolId)
|
||||
.map((item) => ({ value: item.id, label: item.name }))}
|
||||
placeholder={t("support.choose")}
|
||||
value={values.toolId}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-destination`}>{t("support.destination")}</Label>
|
||||
<Input id={`${prefix}-destination`} maxLength={300} {...form.register("destination")} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-state`}>{t("support.state")}</Label>
|
||||
<SelectWithAction
|
||||
id={`${prefix}-state`}
|
||||
label={t("support.state")}
|
||||
onValueChange={(state) => {
|
||||
if (state === "pending" || state === "available" || state === "retired") {
|
||||
form.setValue("state", state);
|
||||
if (state !== "available") {
|
||||
form.setValue("preferred", false);
|
||||
}
|
||||
}
|
||||
}}
|
||||
options={["pending", "available", "retired"].map((state) => ({
|
||||
value: state,
|
||||
label: t(`support.${state}`),
|
||||
}))}
|
||||
placeholder={t("support.chooseState")}
|
||||
value={values.state}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" {...form.register("requiresVpn")} />
|
||||
{t("support.requiresVpn")}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
disabled={values.state !== "available"}
|
||||
type="checkbox"
|
||||
{...form.register("preferred")}
|
||||
/>
|
||||
{t("support.preferred")}
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-notes`}>{t("support.notes")}</Label>
|
||||
<textarea
|
||||
className="min-h-24 w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||
id={`${prefix}-notes`}
|
||||
maxLength={2000}
|
||||
{...form.register("notes")}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
<SupportFeedback
|
||||
error={mutation.error}
|
||||
invalid={Object.keys(form.formState.errors).length > 0}
|
||||
/>
|
||||
<SupportHelp />
|
||||
<FormActions>
|
||||
<Button disabled={mutation.isPending} type="submit">
|
||||
{t(mutation.isPending ? "saving" : "save")}
|
||||
</Button>
|
||||
</FormActions>
|
||||
</form>
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
onOpenChange={setQuick}
|
||||
open={quick}
|
||||
title={t("support.newTool")}
|
||||
>
|
||||
<SupportToolForm
|
||||
csrfToken={csrfToken}
|
||||
onSaved={(id) => {
|
||||
form.setValue("toolId", id);
|
||||
setQuick(false);
|
||||
}}
|
||||
/>
|
||||
</FormDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
30
modules/assets/src/web/support-help.tsx
Normal file
30
modules/assets/src/web/support-help.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { SupportApiError } from "./support-api";
|
||||
export function SupportHelp({ tool = false }: { tool?: boolean }) {
|
||||
const { t } = useTranslation("assets");
|
||||
return (
|
||||
<ContextHelp
|
||||
description={t(tool ? "support.toolDescription" : "support.description")}
|
||||
examples={t(tool ? "support.toolExamples" : "support.examples")}
|
||||
examplesLabel={t("support.examplesLabel")}
|
||||
guidance={t(tool ? "support.toolGuidance" : "support.guidance")}
|
||||
scopeNote={tool ? undefined : t("support.scope")}
|
||||
title={t(tool ? "support.toolHelp" : "support.help")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export function SupportFeedback({ error, invalid = false }: { error: unknown; invalid?: boolean }) {
|
||||
const { t } = useTranslation("assets");
|
||||
const code = invalid
|
||||
? "INVALID_REQUEST"
|
||||
: error instanceof SupportApiError
|
||||
? error.code
|
||||
: "INTERNAL_ERROR";
|
||||
return error || invalid ? (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{t(`support.errors.${code}`, { defaultValue: t("support.errors.INTERNAL_ERROR") })}
|
||||
</p>
|
||||
) : null;
|
||||
}
|
||||
108
modules/assets/src/web/support-i18n.ts
Normal file
108
modules/assets/src/web/support-i18n.ts
Normal file
@ -0,0 +1,108 @@
|
||||
export const supportSpanish = {
|
||||
title: "Asistencia técnica remota",
|
||||
new: "Añadir método",
|
||||
edit: "Editar método",
|
||||
tool: "Herramienta",
|
||||
choose: "Selecciona una herramienta",
|
||||
chooseState: "Selecciona un estado",
|
||||
newTool: "Nueva herramienta",
|
||||
tools: "Herramientas de asistencia",
|
||||
editTool: "Editar herramienta",
|
||||
toolName: "Nombre",
|
||||
active: "Activa",
|
||||
builtin: "De serie",
|
||||
destination: "Identificador o destino (opcional)",
|
||||
requiresVpn: "Requiere VPN",
|
||||
preferred: "Método preferido",
|
||||
state: "Estado",
|
||||
pending: "Pendiente de configurar",
|
||||
available: "Disponible",
|
||||
retired: "Retirado",
|
||||
notes: "Notas (opcional)",
|
||||
empty: "No hay métodos de asistencia registrados para este activo.",
|
||||
showRetired: "Mostrar métodos retirados",
|
||||
history: "Historial",
|
||||
loading: "Cargando asistencia remota…",
|
||||
loadError: "No se pudo cargar la asistencia remota.",
|
||||
help: "¿Para qué sirve este método?",
|
||||
description: "Registra cómo un técnico puede prestar soporte remoto a este activo.",
|
||||
guidance:
|
||||
"Puedes registrar varios métodos. Solo uno disponible puede ser el preferido. Documentar un método no establece una conexión ni concede permisos de teletrabajo.",
|
||||
examples:
|
||||
"AnyDesk: 123 456 789; Supremo Control: su identificador; Escritorio remoto: PORT-ANA-01, con VPN si es necesaria.",
|
||||
examplesLabel: "Ejemplos y recomendaciones",
|
||||
scope: "No introduzcas contraseñas ni claves de acceso en el identificador o las notas.",
|
||||
toolHelp: "¿Para qué sirve una herramienta?",
|
||||
toolDescription: "Catálogo global de herramientas de asistencia, común a todas las compañías.",
|
||||
toolGuidance:
|
||||
"Añade las herramientas que utilicéis. Desactivar impide seleccionarlas en nuevos métodos y conserva los existentes. Los nombres de serie están protegidos.",
|
||||
toolExamples: "AnyDesk, Supremo Control, Escritorio remoto, TeamViewer o RustDesk.",
|
||||
review: "Revisar: el activo está retirado o la herramienta está desactivada.",
|
||||
errors: {
|
||||
INVALID_REQUEST: "Revisa los campos. Solo un método disponible puede marcarse como preferido.",
|
||||
ASSET_NOT_FOUND: "El activo no pertenece a esta compañía o ya no está disponible.",
|
||||
ASSET_RETIRED: "En un activo retirado solo se pueden guardar métodos retirados.",
|
||||
TOOL_UNAVAILABLE: "Selecciona una herramienta activa.",
|
||||
CONNECTION_NOT_FOUND: "El método ya no está disponible.",
|
||||
PREFERRED_EXISTS: "Ya hay un método preferido. Desmárcalo antes de elegir otro.",
|
||||
CONNECTION_EXISTS: "Ya existe este método con el mismo identificador o destino.",
|
||||
TOOL_NOT_FOUND: "La herramienta ya no está disponible.",
|
||||
TOOL_EXISTS: "Ya existe una herramienta con ese nombre.",
|
||||
BUILTIN_PROTECTED: "El nombre de una herramienta de serie no se puede modificar.",
|
||||
INTERNAL_ERROR: "No se pudo completar la operación.",
|
||||
},
|
||||
};
|
||||
export const supportEnglish = {
|
||||
title: "Remote technical assistance",
|
||||
new: "Add method",
|
||||
edit: "Edit method",
|
||||
tool: "Tool",
|
||||
choose: "Choose a tool",
|
||||
chooseState: "Choose a status",
|
||||
newTool: "New tool",
|
||||
tools: "Assistance tools",
|
||||
editTool: "Edit tool",
|
||||
toolName: "Name",
|
||||
active: "Active",
|
||||
builtin: "Built-in",
|
||||
destination: "Identifier or destination (optional)",
|
||||
requiresVpn: "Requires VPN",
|
||||
preferred: "Preferred method",
|
||||
state: "Status",
|
||||
pending: "Pending configuration",
|
||||
available: "Available",
|
||||
retired: "Retired",
|
||||
notes: "Notes (optional)",
|
||||
empty: "No assistance methods recorded for this asset.",
|
||||
showRetired: "Include retired methods",
|
||||
history: "History",
|
||||
loading: "Loading remote assistance…",
|
||||
loadError: "Remote assistance could not be loaded.",
|
||||
help: "What is this method for?",
|
||||
description: "Records how a technician can provide remote support for this asset.",
|
||||
guidance:
|
||||
"You can record several methods. Only one available method can be preferred. Recording a method does not establish a connection or authorize remote work.",
|
||||
examples:
|
||||
"AnyDesk: 123 456 789; Supremo Control: its identifier; Remote Desktop: PORT-ANA-01, with VPN if required.",
|
||||
examplesLabel: "Examples and guidance",
|
||||
scope: "Do not enter passwords or access keys in the identifier or notes.",
|
||||
toolHelp: "What is a tool for?",
|
||||
toolDescription: "Global assistance tool catalog shared across companies.",
|
||||
toolGuidance:
|
||||
"Add tools you use. Deactivation prevents selection in new methods and preserves existing records. Built-in names are protected.",
|
||||
toolExamples: "AnyDesk, Supremo Control, Remote Desktop, TeamViewer or RustDesk.",
|
||||
review: "Review: the asset is retired or the tool is inactive.",
|
||||
errors: {
|
||||
INVALID_REQUEST: "Check the fields. Only an available method can be preferred.",
|
||||
ASSET_NOT_FOUND: "The asset does not belong to this company or is unavailable.",
|
||||
ASSET_RETIRED: "Only retired methods can be saved for a retired asset.",
|
||||
TOOL_UNAVAILABLE: "Select an active tool.",
|
||||
CONNECTION_NOT_FOUND: "This method is unavailable.",
|
||||
PREFERRED_EXISTS: "A preferred method already exists. Uncheck it before choosing another.",
|
||||
CONNECTION_EXISTS: "This method already exists with the same identifier or destination.",
|
||||
TOOL_NOT_FOUND: "This tool is unavailable.",
|
||||
TOOL_EXISTS: "A tool with this name already exists.",
|
||||
BUILTIN_PROTECTED: "Built-in tool names cannot be changed.",
|
||||
INTERNAL_ERROR: "The operation could not be completed.",
|
||||
},
|
||||
};
|
||||
75
modules/assets/src/web/support-tool-form.tsx
Normal file
75
modules/assets/src/web/support-tool-form.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useId } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
type SupportToolDTO,
|
||||
type SupportToolInput,
|
||||
SupportToolInputSchema,
|
||||
} from "../common/support";
|
||||
|
||||
import { supportApi, supportKeys } from "./support-api";
|
||||
import { SupportFeedback, SupportHelp } from "./support-help";
|
||||
export function SupportToolForm({
|
||||
csrfToken,
|
||||
tool,
|
||||
onSaved,
|
||||
}: {
|
||||
csrfToken: string;
|
||||
tool?: SupportToolDTO;
|
||||
onSaved: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("assets");
|
||||
const id = useId();
|
||||
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) => supportApi.saveTool(values, csrfToken, tool?.id),
|
||||
onSuccess: async (response) => {
|
||||
await Promise.all([
|
||||
client.invalidateQueries({ queryKey: supportKeys.tools }),
|
||||
client.invalidateQueries({ queryKey: ["asset-support"] }),
|
||||
]);
|
||||
onSaved(response.tool.id);
|
||||
},
|
||||
});
|
||||
return (
|
||||
<form className="space-y-5" onSubmit={form.handleSubmit((values) => mutation.mutate(values))}>
|
||||
<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>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" {...form.register("active")} />
|
||||
{t("support.active")}
|
||||
</label>
|
||||
</fieldset>
|
||||
<SupportFeedback
|
||||
error={mutation.error}
|
||||
invalid={Object.keys(form.formState.errors).length > 0}
|
||||
/>
|
||||
<SupportHelp tool />
|
||||
<FormActions>
|
||||
<Button disabled={mutation.isPending} type="submit">
|
||||
{t(mutation.isPending ? "saving" : "save")}
|
||||
</Button>
|
||||
</FormActions>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
77
modules/assets/src/web/support-tools.tsx
Normal file
77
modules/assets/src/web/support-tools.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { SupportToolDTO } from "../common/support";
|
||||
|
||||
import { supportApi, supportKeys } from "./support-api";
|
||||
import { SupportToolForm } from "./support-tool-form";
|
||||
export function SupportTools({ csrfToken }: { csrfToken: string }) {
|
||||
const { t } = useTranslation("assets");
|
||||
const query = useQuery({ queryKey: supportKeys.tools, queryFn: () => supportApi.tools() });
|
||||
const [edit, setEdit] = useState<SupportToolDTO | undefined>();
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<header className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEdit(undefined);
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("support.newTool")}
|
||||
</Button>
|
||||
</header>
|
||||
{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>
|
||||
)}
|
||||
<ul className="divide-y">
|
||||
{query.data?.tools.map((tool) => (
|
||||
<li className="flex items-center justify-between gap-3 py-3" key={tool.id}>
|
||||
<span>
|
||||
{tool.name} · {t(tool.active ? "support.active" : "inactive")}
|
||||
{tool.builtin ? ` · ${t("support.builtin")}` : ""}
|
||||
</span>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEdit(tool);
|
||||
setOpen(true);
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
onOpenChange={setOpen}
|
||||
open={open}
|
||||
title={t(edit ? "support.editTool" : "support.newTool")}
|
||||
>
|
||||
<SupportToolForm
|
||||
csrfToken={csrfToken}
|
||||
key={edit?.id ?? "new"}
|
||||
{...(edit ? { tool: edit } : {})}
|
||||
onSaved={() => setOpen(false)}
|
||||
/>
|
||||
</FormDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
modules/assets/src/web/support.test.tsx
Normal file
81
modules/assets/src/web/support.test.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createInstance } from "i18next";
|
||||
import type { ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
|
||||
import { SupportSummary } from "./asset-support";
|
||||
import { assetsEnglish, assetsSpanish } from "./i18n";
|
||||
import { SupportForm } from "./support-form";
|
||||
import { SupportToolForm } from "./support-tool-form";
|
||||
|
||||
const tool = {
|
||||
id: UniqueID.generateNewID().toPrimitive(),
|
||||
name: "AnyDesk",
|
||||
active: true,
|
||||
builtin: true,
|
||||
};
|
||||
async function render(node: ReactNode, language = "es") {
|
||||
const i18n = createInstance();
|
||||
await i18n.init({
|
||||
lng: language,
|
||||
resources: { es: { assets: assetsSpanish }, en: { assets: assetsEnglish } },
|
||||
});
|
||||
const client = new QueryClient();
|
||||
try {
|
||||
return renderToStaticMarkup(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<QueryClientProvider client={client}>{node}</QueryClientProvider>
|
||||
</I18nextProvider>
|
||||
);
|
||||
} finally {
|
||||
client.clear();
|
||||
}
|
||||
}
|
||||
test("asistencia: formulario con alta rápida y ayuda antes de guardar", async () => {
|
||||
const html = await render(
|
||||
<SupportForm
|
||||
assetId={UniqueID.generateNewID().toPrimitive()}
|
||||
companyId={UniqueID.generateNewID().toPrimitive()}
|
||||
csrfToken="test"
|
||||
onSaved={() => undefined}
|
||||
tools={[tool]}
|
||||
/>
|
||||
);
|
||||
assert.ok(html.includes("Nueva herramienta"));
|
||||
assert.ok(html.includes("Identificador o destino (opcional)"));
|
||||
assert.ok(html.indexOf("¿Para qué sirve este método?") < html.indexOf("Guardar"));
|
||||
assert.equal(html.includes('type="password"'), false);
|
||||
assert.ok(html.includes("Requiere VPN"));
|
||||
assert.ok(html.includes("Método preferido"));
|
||||
});
|
||||
test("asistencia: traducciones y resumen de método", async () => {
|
||||
const values = {
|
||||
toolId: tool.id,
|
||||
destination: "PORT-ANA-01",
|
||||
requiresVpn: true,
|
||||
preferred: true,
|
||||
state: "available" as const,
|
||||
notes: "",
|
||||
};
|
||||
for (const [language, expected] of [
|
||||
["es", "Disponible"],
|
||||
["en", "Available"],
|
||||
]) {
|
||||
const html = await render(<SupportSummary tools={[tool]} values={values} />, language);
|
||||
assert.ok(html.includes(expected ?? ""));
|
||||
assert.ok(html.includes("PORT-ANA-01"));
|
||||
assert.ok(html.includes("AnyDesk"));
|
||||
}
|
||||
});
|
||||
test("herramienta: nombre de serie protegido y ayuda inferior", async () => {
|
||||
const html = await render(
|
||||
<SupportToolForm csrfToken="test" onSaved={() => undefined} tool={tool} />
|
||||
);
|
||||
assert.ok(html.includes("readOnly"));
|
||||
assert.ok(html.indexOf("¿Para qué sirve una herramienta?") < html.indexOf("Guardar"));
|
||||
});
|
||||
@ -1,4 +1,8 @@
|
||||
import { inventoryApi as assetApi, inventoryKeys as assetKeys } from "@metis/assets/client";
|
||||
import {
|
||||
AssetSupport,
|
||||
inventoryApi as assetApi,
|
||||
inventoryKeys as assetKeys,
|
||||
} from "@metis/assets/client";
|
||||
import type { AssetDTO } from "@metis/assets/common";
|
||||
import {
|
||||
inventoryApi as resourceApi,
|
||||
@ -137,11 +141,13 @@ function useEnvironmentQueries(companyId: string) {
|
||||
export function WorkEnvironmentPage({
|
||||
company,
|
||||
profileId,
|
||||
csrfToken,
|
||||
onManage,
|
||||
onProfile,
|
||||
}: {
|
||||
company: CompanyDTO;
|
||||
profileId: string;
|
||||
csrfToken: string;
|
||||
onManage: (kind: ProfileConfigurationKind | "telework" | "remote") => void;
|
||||
onProfile: (id: string) => void;
|
||||
}) {
|
||||
@ -176,6 +182,7 @@ export function WorkEnvironmentPage({
|
||||
authorizations={query.data.authorizations}
|
||||
company={company}
|
||||
connections={query.data.connections}
|
||||
csrfToken={csrfToken}
|
||||
key={profile.id}
|
||||
onManage={onManage}
|
||||
onProfile={onProfile}
|
||||
@ -192,6 +199,7 @@ function EnvironmentContent({
|
||||
profile,
|
||||
profiles,
|
||||
sources,
|
||||
csrfToken,
|
||||
onManage,
|
||||
onProfile,
|
||||
}: {
|
||||
@ -201,6 +209,7 @@ function EnvironmentContent({
|
||||
authorizations: readonly TeleworkAuthorizationDTO[];
|
||||
profiles: readonly WorkProfileDTO[];
|
||||
sources: EnvironmentSources;
|
||||
csrfToken: string;
|
||||
onManage: (kind: ProfileConfigurationKind | "telework" | "remote") => void;
|
||||
onProfile: (id: string) => void;
|
||||
}) {
|
||||
@ -352,6 +361,12 @@ function EnvironmentContent({
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<AssetSupport
|
||||
assetId={profile.values.assetId}
|
||||
companyId={company.id}
|
||||
csrfToken={csrfToken}
|
||||
retired={model.host?.values.status === "retired"}
|
||||
/>
|
||||
<section aria-label={t("environment.configurations")} className="space-y-4">
|
||||
<dl className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
{(["configured", "pending", "removal", "review"] as const).map((key) => (
|
||||
|
||||
@ -388,6 +388,7 @@ async function render(
|
||||
<QueryClientProvider client={client}>
|
||||
<WorkEnvironmentPage
|
||||
company={company}
|
||||
csrfToken="test"
|
||||
onManage={() => undefined}
|
||||
onProfile={() => undefined}
|
||||
profileId={profile.id}
|
||||
|
||||
@ -318,6 +318,7 @@ function ProfileConfigurations({
|
||||
{selection.kind === "environment" && (
|
||||
<WorkEnvironmentPage
|
||||
company={company}
|
||||
csrfToken={csrfToken}
|
||||
onManage={(kind) =>
|
||||
onSelection({ kind, profileId: selection.profileId, fromEnvironment: true })
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user