Asistencia técnica remota

This commit is contained in:
David Arranz 2026-10-07 10:16:39 +02:00
parent dffb4b2794
commit ea1e5e450f
39 changed files with 2042 additions and 4 deletions

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

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

View File

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

View File

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

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

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -29,4 +29,5 @@ export type SaveDefinitionResponseDTO = z.infer<typeof SaveDefinitionResponseSch
export * from "./inventory";
export * from "./relationships";
export * from "./support";
export * from "./workstations";

View 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>;

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

View File

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

View File

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

View File

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

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

View 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>
</>
);
}

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

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

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

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

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

View File

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

View File

@ -388,6 +388,7 @@ async function render(
<QueryClientProvider client={client}>
<WorkEnvironmentPage
company={company}
csrfToken="test"
onManage={() => undefined}
onProfile={() => undefined}
profileId={profile.id}

View File

@ -318,6 +318,7 @@ function ProfileConfigurations({
{selection.kind === "environment" && (
<WorkEnvironmentPage
company={company}
csrfToken={csrfToken}
onManage={(kind) =>
onSelection({ kind, profileId: selection.profileId, fromEnvironment: true })
}