Intervenciones y plantillas

This commit is contained in:
David Arranz 2026-10-07 13:03:01 +02:00
parent 0a4d3e1b62
commit 1989bbff89
67 changed files with 5594 additions and 16 deletions

View File

@ -19,6 +19,8 @@ import { createResourceModule } from "@metis/resources/module";
import { QueryTypes } from "sequelize";
import { z } from "zod/v4";
import { migrateInterventionTemplates } from "../../../modules/companies/src/api/infrastructure/persistence/intervention-template-migration";
import { verifyAccess } from "./access.integration";
import { createApp } from "./app";
import { verifyApplications } from "./applications.integration";
@ -26,6 +28,8 @@ import { verifyAssets } from "./assets.integration";
import { verifyCertificates } from "./certificates.integration";
import { createDatabase, readDatabaseEnvironment } from "./config/database";
import { createIdentityRuntime } from "./infrastructure/http/identity-runtime";
import { verifyInterventionTemplates } from "./intervention-templates.integration";
import { verifyInterventions } from "./interventions.integration";
import { verifyInventory } from "./inventory.integration";
import { verifyMail } from "./mail.integration";
import { verifyPrinters } from "./printers.integration";
@ -53,6 +57,12 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
await once(server, "listening");
await database.query("UPDATE identity_state SET initialAdminId = NULL WHERE id = 1");
await database.query("DELETE FROM http_sessions");
await database.query("DELETE FROM intervention_revisions");
await database.query("DELETE FROM interventions");
await database.query("UPDATE intervention_counter SET nextNumber=1 WHERE id=1");
await database.query("DELETE FROM intervention_template_revisions");
await database.query("DELETE FROM intervention_templates");
await migrateInterventionTemplates(database);
await database.query("DELETE FROM ticket_revisions");
await database.query("DELETE FROM tickets");
await database.query("UPDATE ticket_counter SET nextNumber=1 WHERE id=1");
@ -822,7 +832,18 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
database
);
await verifySupport((...args) => operator.request(...args), customer.id, secondCompany.id);
await verifyInterventionTemplates(
(...args) => operator.request(...args),
customer.id,
secondCompany.id,
() => migrateInterventionTemplates(database)
);
await verifyTickets((...args) => operator.request(...args), customer.id, secondCompany.id);
await verifyInterventions(
(...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");
@ -919,6 +940,12 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
401,
"UNAUTHENTICATED"
);
await expectError(
await operator.request("/intervention-templates/global"),
401,
"UNAUTHENTICATED"
);
await expectError(await operator.request("/interventions"), 401, "UNAUTHENTICATED");
await expectError(await operator.request("/tickets"), 401, "UNAUTHENTICATED");
await expectError(await operator.request("/users"), 401, "UNAUTHENTICATED");
await expectError(await operator.request("/companies"), 401, "UNAUTHENTICATED");

View File

@ -0,0 +1,123 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import {
type InterventionTemplateDTO,
InterventionTemplateResponseSchema,
InterventionTemplatesSchema,
} from "@metis/companies/common";
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
export async function verifyInterventionTemplates(
request: Request,
companyId: string,
otherId: string,
migrate: () => Promise<void>
) {
const path = "/intervention-templates/global";
const list = InterventionTemplatesSchema.parse(await (await request(path)).json());
assert.equal(list.templates.length, 6);
assert.ok(list.templates.every((template) => template.companyId === null));
const values = {
name: "Procedimiento de prueba",
description: "Prueba",
active: true,
steps: [
{ id: randomUUID(), title: "Primer paso", instructions: "", required: false },
{ id: randomUUID(), title: "Segundo paso", instructions: "Verificar", required: true },
],
};
const body = { values, requestKey: randomUUID(), expectedRevisionId: null };
async function save(url: string, payload: unknown, status = 201) {
const response = await request(url, payload);
assert.equal(response.status, status, await response.clone().text());
return InterventionTemplateResponseSchema.parse(await response.json()).template;
}
const created = await save(path, body);
assert.equal(created.id[14], "7");
assert.equal(created.history.length, 1);
assert.equal((await save(path, body)).id, created.id);
assert.equal((await request(path, { ...body, values: { ...values, name: "Otro" } })).status, 409);
assert.equal((await request(path, { ...body, requestKey: randomUUID() })).status, 409);
assert.equal((await request(`/intervention-templates/${companyId}`, body)).status, 409);
const local = await save(`/intervention-templates/${companyId}`, {
...body,
requestKey: randomUUID(),
});
assert.equal(local.companyId, companyId);
const scoped = InterventionTemplatesSchema.parse(
await (await request(`/intervention-templates/${companyId}`)).json()
);
assert.ok(scoped.templates.some((item) => item.id === created.id));
assert.ok(scoped.templates.some((item) => item.id === local.id));
const other = InterventionTemplatesSchema.parse(
await (await request(`/intervention-templates/${otherId}`)).json()
);
assert.ok(other.templates.every((item) => item.companyId === null));
const updateBody = (template: InterventionTemplateDTO) => ({
values: { ...template.values, active: false, steps: [...template.values.steps].reverse() },
requestKey: randomUUID(),
expectedRevisionId: template.history.at(-1)?.id,
});
assert.equal(
(
await request(
`/intervention-templates/${companyId}/${created.id}/update`,
updateBody(created)
)
).status,
404
);
assert.equal(
(await request(`/intervention-templates/${otherId}/${local.id}/update`, updateBody(local)))
.status,
404
);
const updated = await save(`${path}/${created.id}/update`, updateBody(created), 200);
assert.equal(updated.history.length, 2);
assert.equal(updated.values.active, false);
assert.equal(updated.history[0]?.values.active, true);
assert.equal(updated.values.steps[0]?.id, values.steps[1]?.id);
const same = await save(
`${path}/${created.id}/update`,
{ ...body, values: updated.values, expectedRevisionId: updated.history.at(-1)?.id },
200
);
assert.equal(same.history.length, 2);
assert.equal((await request(`${path}/${created.id}/update`, updateBody(created))).status, 409);
const responses = await Promise.all(
["Una edición", "Otra edición"].map((description) =>
request(`${path}/${created.id}/update`, {
...updateBody(updated),
values: { ...updated.values, description },
})
)
);
assert.deepEqual(responses.map((response) => response.status).sort(), [200, 409]);
assert.equal(
(await request(path, { ...body, requestKey: randomUUID(), values: { ...values, steps: [] } }))
.status,
400
);
assert.equal((await request(path, { ...body, unknown: true })).status, 400);
assert.equal((await request(`${path}?unknown=1`)).status, 400);
assert.equal((await request(`/intervention-templates/${randomUUID()}`)).status, 404);
assert.equal((await request(path, body, "http://evil.example")).status, 403);
assert.equal((await request(path, body, undefined, "invalid")).status, 403);
const seed = list.templates[0];
assert.ok(seed);
const modifiedSeed = await save(
`${path}/${seed.id}/update`,
{
...updateBody(seed),
values: { ...seed.values, active: false, description: "Cambio conservado tras migración" },
},
200
);
await migrate();
await migrate();
const afterMigration = InterventionTemplatesSchema.parse(await (await request(path)).json());
const retained = afterMigration.templates.find((item) => item.id === seed.id);
assert.deepEqual(retained, modifiedSeed);
assert.equal(afterMigration.templates.length, 7);
}

View File

@ -0,0 +1,240 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import {
type InterventionDTO,
type InterventionInput,
InterventionListSchema,
InterventionLookupsSchema,
InterventionResponseSchema,
InterventionTemplateResponseSchema,
TicketListSchema,
} from "@metis/companies/common";
import { z } from "zod/v4";
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
export async function verifyInterventions(request: Request, companyId: string, otherId: string) {
const path = `/interventions/${companyId}`;
const lookupResponse = await request(`${path}/lookups`);
assert.equal(lookupResponse.status, 200);
const lookups = InterventionLookupsSchema.parse(await lookupResponse.json());
const contextResponse = await request("/interventions");
assert.equal(contextResponse.status, 200, await contextResponse.clone().text());
const context = InterventionListSchema.parse(await contextResponse.json());
const technician = context.technicians.find((item) => item.active);
const asset = lookups.assets.find((item) => item.active);
const ticket = lookups.tickets[0];
assert.ok(technician && asset && ticket);
const values: InterventionInput = {
subject: "Actuación sencilla",
ticketId: null,
assigneeId: null,
state: "pending",
modality: "remote",
plannedDate: null,
startedAt: null,
endedAt: null,
minutes: null,
diagnosis: "",
workPerformed: "",
resolution: "",
affected: [],
steps: [],
};
const body = { values, template: null, requestKey: randomUUID(), expectedRevisionId: null };
async function save(url: string, payload: unknown, status = 201) {
const response = await request(url, payload);
assert.equal(response.status, status, await response.clone().text());
return InterventionResponseSchema.parse(await response.json()).intervention;
}
async function error(url: string, payload: unknown, status: number, code: string) {
const response = await request(url, payload);
assert.equal(response.status, status, await response.clone().text());
assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code);
}
const simple = await save(path, body);
assert.equal(simple.id[14], "7");
assert.match(simple.reference, /^I-[0-9]{6,}$/);
assert.equal(simple.history.length, 1);
assert.equal(simple.values.minutes, null);
assert.deepEqual(simple.values.steps, []);
assert.equal(simple.procedure, null);
assert.equal((await save(path, body)).id, simple.id);
await error(path, { ...body, values: { ...values, subject: "Otra" } }, 409, "REQUEST_KEY_REUSED");
await error(`/interventions/${otherId}`, body, 409, "REQUEST_KEY_REUSED");
const update = (item: InterventionDTO, next: InterventionInput) => ({
values: next,
template: null,
requestKey: randomUUID(),
expectedRevisionId: item.history.at(-1)?.id,
});
const same = await save(`${path}/${simple.id}/update`, update(simple, values), 200);
assert.equal(same.history.length, 1);
await error(
path,
{ ...body, requestKey: randomUUID(), values: { ...values, state: "in_progress" } },
400,
"INVALID_REQUEST"
);
const finished = await save(
`${path}/${simple.id}/update`,
update(simple, {
...values,
state: "finished",
assigneeId: technician.id,
workPerformed: "Atasco resuelto",
minutes: 5,
}),
200
);
assert.equal(finished.history.length, 2);
assert.equal(finished.values.state, "finished");
await error(`${path}/${simple.id}/update`, update(simple, values), 409, "INTERVENTION_CHANGED");
await error(
`/interventions/${otherId}/${simple.id}/update`,
update(finished, values),
404,
"INTERVENTION_NOT_FOUND"
);
const concurrent = await Promise.all(
["Nota A", "Nota B"].map((resolution) =>
request(`${path}/${simple.id}/update`, update(finished, { ...finished.values, resolution }))
)
);
assert.deepEqual(concurrent.map((response) => response.status).sort(), [200, 409]);
const linked = await save(path, {
...body,
requestKey: randomUUID(),
values: {
...values,
ticketId: ticket.id,
assigneeId: technician.id,
affected: [{ kind: "asset", targetId: asset.id }],
},
});
assert.equal(linked.ticketReference, ticket.reference);
await error(
`/interventions/${otherId}`,
{ ...body, requestKey: randomUUID(), values: { ...values, ticketId: ticket.id } },
404,
"TICKET_NOT_FOUND"
);
await error(
`/interventions/${otherId}`,
{
...body,
requestKey: randomUUID(),
values: { ...values, affected: [{ kind: "asset", targetId: asset.id }] },
},
409,
"TARGET_UNAVAILABLE"
);
await error(
path,
{ ...body, requestKey: randomUUID(), values: { ...values, assigneeId: randomUUID() } },
409,
"ASSIGNEE_UNAVAILABLE"
);
const globalTemplate = lookups.templates.find(
(item) => item.values.active && item.companyId === null
);
assert.ok(globalTemplate);
const step = {
id: randomUUID(),
title: "Comprobar correo",
instructions: "Probar envío",
required: true,
state: "pending" as const,
notes: "",
};
const templatedBody = {
...body,
requestKey: randomUUID(),
template: { id: globalTemplate.id, revisionId: globalTemplate.history.at(-1)?.id },
values: { ...values, subject: "Revisar con procedimiento", ticketId: ticket.id, steps: [step] },
};
const templated = await save(path, templatedBody);
assert.equal(templated.procedure?.values.name, globalTemplate.values.name);
await error(
path,
{
...templatedBody,
requestKey: randomUUID(),
template: { ...templatedBody.template, revisionId: randomUUID() },
},
409,
"TEMPLATE_CHANGED"
);
const beforeTemplateUpdate = await request(
`/intervention-templates/global/${globalTemplate.id}/update`,
{
values: { ...globalTemplate.values, active: false, description: "Plantilla cambiada" },
requestKey: randomUUID(),
expectedRevisionId: globalTemplate.history.at(-1)?.id,
}
);
assert.equal(beforeTemplateUpdate.status, 200);
InterventionTemplateResponseSchema.parse(await beforeTemplateUpdate.json());
assert.equal((await save(path, templatedBody)).id, templated.id);
await error(path, { ...templatedBody, requestKey: randomUUID() }, 409, "TEMPLATE_UNAVAILABLE");
const persisted = InterventionListSchema.parse(
await (await request(path)).json()
).interventions.find((item) => item.id === templated.id);
assert.deepEqual(persisted?.procedure, templated.procedure);
await error(
`${path}/${templated.id}/update`,
update(templated, {
...templated.values,
state: "finished",
assigneeId: technician.id,
workPerformed: "Comprobado",
}),
400,
"INVALID_REQUEST"
);
await error(
`${path}/${templated.id}/update`,
update(templated, {
...templated.values,
state: "finished",
assigneeId: technician.id,
workPerformed: "Comprobado",
steps: [{ ...step, state: "not_applicable" }],
}),
400,
"INVALID_REQUEST"
);
const done = await save(
`${path}/${templated.id}/update`,
update(templated, {
...templated.values,
state: "finished",
assigneeId: technician.id,
workPerformed: "Comprobado",
steps: [{ ...step, state: "not_applicable", notes: "No utiliza correo" }],
}),
200
);
assert.equal(done.values.steps[0]?.notes, "No utiliza correo");
assert.equal(done.procedure?.revisionId, templated.procedure?.revisionId);
const ticketAfter = TicketListSchema.parse(
await (await request(`/tickets/${companyId}`)).json()
).tickets.find((item) => item.id === ticket.id);
assert.equal(ticketAfter?.values.state, ticket.values.state);
await error(
`${path}/${done.id}/update`,
{ ...update(done, done.values), template: templatedBody.template },
400,
"INVALID_REQUEST"
);
const other = await save(`/interventions/${otherId}`, { ...body, requestKey: randomUUID() });
assert.notEqual(other.reference, simple.reference);
const all = InterventionListSchema.parse(await (await request("/interventions")).json());
assert.ok(all.interventions.some((item) => item.id === other.id));
const scoped = InterventionListSchema.parse(await (await request(path)).json());
assert.ok(scoped.interventions.every((item) => item.companyId === companyId));
assert.equal((await request(path, { ...body, unexpected: true })).status, 400);
assert.equal((await request("/interventions?unknown=1")).status, 400);
assert.equal((await request(path, body, "http://evil.example")).status, 403);
assert.equal((await request(path, body, undefined, "invalid")).status, 403);
}

View File

@ -94,3 +94,11 @@ SupportConnection delimita cada método de asistencia de un activo de una compa
## Tickets
Ticket es AggregateRoot con compañía inmutable, referencia global y TicketValues (Name, UniqueID/Maybe y referencias tipadas TicketTarget). TicketRevision conserva cada instantánea con UUID propio, actor y fecha. Las referencias afectadas son valores sin ciclo de vida independiente, no entidades. Alta idempotente por clave de solicitud y edición condicionada a la última revisión. Consultar [Tickets](../tickets.md).
## Plantillas de intervención
InterventionTemplate es un agregado con ámbito inmutable, clave idempotente y revisiones. InterventionTemplateValues compara semánticamente nombre, descripción, estado y pasos en orden. InterventionTemplateStep e InterventionTemplateRevision son entidades con UUID. Las copias generan nuevos identificadores de pasos; editar o reordenar los conserva. Es un catálogo separado de ProcessTemplate y de las plantillas de activos/recursos.
## Intervenciones
Intervention es AggregateRoot con compañía, referencia, clave idempotente y procedimiento opcional inmutables. InterventionValues es Value Object, InterventionStep e InterventionRevision son DomainEntity. Los pasos mantienen UUID al actualizar/reordenar y generan identidades nuevas al aplicar plantillas. InterventionProcedure captura la identidad, revisión y valores originales de una plantilla mediante un Value Object inmutable. El historial permite conocer quién completó cada paso, incluso tras editar sus notas.

View File

@ -0,0 +1,104 @@
# Intervenciones y plantillas
## Alcance implementado
Catálogo independiente de las plantillas de procesos de personas y de los catálogos de activos y recursos. Cada plantilla representa un procedimiento repetible. No almacena personas, equipos, cuentas ni accesos concretos de una compañía.
Disponible en Catálogos globales y en la sección Plantillas de intervención de cada compañía. La compañía ve las globales y sus propias plantillas. Las globales solo se editan desde su catálogo; copiarlas desde una compañía crea una variante local independiente.
## Datos y reglas
- Nombre obligatorio, hasta 160 caracteres, único en su ámbito incluso estando inactiva.
- Descripción opcional, hasta 4.000 caracteres.
- Activa/inactiva. No hay borrado; se conserva el historial.
- Entre 1 y 100 pasos ordenados. Cada paso tiene UUID, título obligatorio hasta 200 caracteres, instrucciones opcionales hasta 4.000 caracteres e indicador de comprobación obligatoria antes de finalizar.
- Los pasos admiten títulos iguales: se identifican por UUID, nunca por su texto.
- Crear y copiar genera UUID v7 para agregados, revisiones y pasos desde la interfaz. Reordenar o editar mantiene la identidad del paso.
- Copiar crea pasos con identidades nuevas y una plantilla activa, sin copiar su historial. Guardar exige un nombre que no exista en el ámbito destino.
- Compañía inmutable. Una variante se crea por copia.
- Las ediciones conservan instantáneas completas y ordenadas con actor y fecha. Un guardado sin cambios no añade revisiones.
- Alta idempotente mediante requestKey: el reintento devuelve la plantilla existente si coincide con los valores originales, actor y ámbito. Reutilizar la clave con otra información devuelve conflicto.
- Edición condicionada a expectedRevisionId: una edición desactualizada se rechaza sin sobrescribir cambios.
- Operaciones serializadas mediante bloqueo transaccional de catálogo y restricciones de claves en MariaDB.
## Datos iniciales
Se suministran seis procedimientos orientativos: alta de persona, baja de persona, sustitución de equipo, configuración de correo, preparación de portátil para teletrabajo y revisión periódica de equipo. Sus comprobaciones son inicialmente opcionales para permitir adaptación. Se pueden editar y desactivar.
Las claves de incorporación son estables: reiniciar o repetir la migración no duplica ni restaura los datos iniciales modificados. Nunca se incorporan datos operativos de compañías.
## UI
Listado con búsqueda, filtro de inactivas, ámbito y acciones de editar/copiar. Alta mediante botón en la cabecera derecha. Alta y edición en modal con pasos que se pueden añadir, quitar y mover arriba/abajo. Ayuda contextual inmediatamente antes del pie Guardar/Cancelar. Historial desplegable con las instantáneas completas. Textos es/en.
## Arquitectura y API
InterventionTemplate es AggregateRoot; InterventionTemplateValues es Value Object; InterventionTemplateStep e InterventionTemplateRevision son DomainEntity. Todo tiene identidad UniqueID.
- GET /api/intervention-templates/global
- GET /api/intervention-templates/:companyId (globales y propias)
- POST /api/intervention-templates/:scope
- POST /api/intervention-templates/:scope/:id/update
scope es global o UUID de compañía. Escritura: values, requestKey y expectedRevisionId (null en alta). Entrada estricta, autenticación, protección de origen y CSRF del servidor. La edición debe corresponder exactamente al ámbito de la URL.
Persistencia en intervention_templates e intervention_template_revisions; intervention_template_lock es un bloqueo técnico, no una entidad de negocio. Migración explícita e idempotente. Mapper bidireccional con validación del historial, posiciones, fechas y coherencia de última revisión.
## Intervenciones implementadas
Intervenciones por compañía y vista global en la barra de espacios (I). Búsqueda por referencia, asunto, compañía y técnico; filtros de estado, técnico, compañía y rango de fecha prevista. Sin filtros se muestran todas las pendientes, en curso, finalizadas y canceladas.
### Datos
- Compañía obligatoria e inmutable.
- UUID v7 y referencia global I-000001.
- Ticket opcional de la misma compañía. Un ticket admite varias intervenciones; una actuación interna no exige ticket.
- Asunto obligatorio (200 caracteres).
- Estado: pendiente, en curso, finalizada o cancelada.
- Técnico responsable opcional mientras esté pendiente/cancelada; obligatorio en curso/finalizada.
- Modalidad: remota, presencial u otra.
- Fecha prevista, inicio/fin reales opcionales. El fin no puede ser anterior al inicio.
- Tiempo manual en minutos enteros, opcional; null significa desconocido, cero es un valor conocido. No se calcula automáticamente por diferencia de fechas.
- Diagnóstico, trabajo realizado y resolución (10.000 caracteres cada uno). Trabajo realizado obligatorio para finalizar.
- Múltiples personas, puestos de trabajo, activos y recursos afectados de la misma compañía.
- Pasos opcionales con UUID, título, instrucciones, indicador obligatorio, estado (pendiente/en curso/hecho/no aplica) y notas.
### Procedimientos
Una intervención sencilla puede existir sin pasos ni plantilla. Los pasos pueden añadirse manualmente. En el alta se puede aplicar una plantilla global o de la compañía; Aplicar sustituye los pasos actuales, como indica el formulario, y genera nuevas identidades para la ejecución. Se pueden adaptar los pasos.
Se captura en InterventionProcedure la identidad y revisión de la plantilla junto con todos sus valores originales. Esta procedencia es inmutable; editar o desactivar la plantilla no cambia la intervención. La plantilla debe estar activa y la revisión elegida vigente al crear la intervención. El reintento de un alta ya guardada sigue devolviendo su intervención aunque la plantilla haya cambiado.
Finalizar exige técnico, trabajo realizado y que cada paso obligatorio esté hecho o marcado no aplica con motivo. Los pasos opcionales pendientes no bloquean. La ejecución de los pasos queda reflejada en el historial; la ficha identifica quién y cuándo pasó cada paso a hecho. Editar sus notas no cambia ese autor/fecha, pero reabrirlo y completarlo de nuevo sí.
No se modifican automáticamente cuentas, activos ni recursos al marcar un paso. Finalizar una intervención no cambia el estado del ticket. Se puede reabrir una intervención conservando su historial.
### Formularios y tickets
Alta y edición en modales con ayuda antes del pie. La selección de compañía queda fija al editar o iniciar desde un ticket. Cambiar la compañía en el alta limpia ticket, afectados, procedimiento y pasos. Seleccionar ticket propone asunto, afectados y técnico solo cuando esos campos están vacíos.
Desde un ticket se puede crear una intervención con asunto y afectados propuestos. La ficha del ticket consulta sus intervenciones relacionadas. Altas rápidas de compañía, ticket, plantilla y elementos afectados mediante SelectWithAction, manteniendo el borrador principal.
### Arquitectura y persistencia
Intervention es AggregateRoot; InterventionValues e InterventionProcedure son Value Objects; InterventionStep e InterventionRevision son DomainEntity con UUID. Igualdad semántica; el orden de pasos importa, el de afectados no.
Application valida compañía, ticket, técnico y afectados, admitiendo referencias inactivas ya existentes pero rechazando nuevas asignaciones a elementos inactivos. Collaboración con puertos del mismo módulo; el inventario se obtiene a través de los servicios públicos ya utilizados por tickets.
Persistencia en interventions e intervention_revisions con FK a compañía, ticket y técnico. intervention_counter es una secuencia técnica. Alta idempotente mediante requestKey, revisión esperada al editar y bloqueo transaccional para evitar sobrescrituras concurrentes. Snapshot e historial coherentes mediante mapper bidireccional. Migración explícita e idempotente.
API autenticada, protección de origen/CSRF y entrada estricta:
- GET /api/interventions (global)
- GET /api/interventions/:companyId
- GET /api/interventions/:companyId/lookups
- POST /api/interventions/:companyId
- POST /api/interventions/:companyId/:id/update
Escritura: values, requestKey, expectedRevisionId (null en alta) y template (null o id/revisionId durante el alta; null al editar). La procedencia aplicada no se cambia durante una edición.
## Validación
Pruebas de dominio y persistencia: límites, pasos repetidos por UUID, igualdad semántica, orden, no-op, fechas e historial. Pruebas web es/en, ayuda inferior, listado sin formulario y copia con identidades nuevas. Integración HTTP/MariaDB: autenticación, CSRF, origen, ámbitos, altas, duplicados, reintentos, edición, concurrencia, historial y conservación de datos iniciales tras repetir migraciones.
Las pruebas de intervenciones cubren actuaciones sencillas, requisitos de cierre, no aplicación justificada, fechas, minutos, historial, snapshots, idempotencia, concurrencia, compañía de ticket/afectados, procedimientos versionados, no cierre automático del ticket, filtros es/en y autor de finalización de pasos.

View File

@ -85,8 +85,7 @@ Varios métodos por activo, catálogo global ampliable con herramientas de serie
## Siguientes entregas: orden actualizado
1. Intervenciones: trabajo realizado, técnico, tiempos 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.
1. 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.
@ -100,9 +99,9 @@ Las [relaciones entre activos](relaciones-entre-activos.md) están implementadas
Tickets por compañía y consulta global con búsqueda/filtros, referencia corta global además de UUID, solicitante y responsable opcionales, canales, estados, prioridades y varios elementos afectados. Modales, altas rápidas, historial, altas idempotentes y protección de edición concurrente. Consultar [Tickets](tickets.md).
## Intervenciones: pendientes
## Intervenciones: implementadas
Fecha, técnico, tiempo empleado, diagnóstico, trabajo realizado, resultado y elementos afectados. Un ticket puede generar varias intervenciones; también pueden existir intervenciones internas sin ticket.
Consulta global y por compañía, técnico, tiempos, diagnóstico, trabajo realizado, resultado y elementos afectados. Varias intervenciones por ticket o actuaciones internas sin ticket. Pasos opcionales, procedimientos versionados, comprobaciones obligatorias al finalizar, historial, idempotencia y control de concurrencia. Consultar [Intervenciones y plantillas](intervenciones-y-plantillas.md).
## Servicios y documentación: pendiente
@ -111,4 +110,8 @@ Las credenciales se resolverán con una solución específica; no almacenarlas c
## Decisiones pendientes
Permisos por técnico si llegan a necesitarse, despliegue, backups, recuperación administrativa y modelo de organización. El acceso y los usuarios ya están implementados; la base de compañías clientes, personas, departamentos, asignaciones y sedes está implementada. El inventario está implementado; los tickets están implementados; las intervenciones siguen pendientes.
Permisos por técnico si llegan a necesitarse, despliegue, backups, recuperación administrativa y modelo de organización. El acceso y los usuarios ya están implementados; la base de compañías clientes, personas, departamentos, asignaciones y sedes está implementada. El inventario está implementado; los tickets están implementados; las intervenciones están implementadas.
## Plantillas de intervención
Implementadas las plantillas globales y propias por compañía con pasos ordenados, historial y seis procedimientos iniciales. Véase [Intervenciones y plantillas](intervenciones-y-plantillas.md). Las intervenciones concretas y la ejecución de los pasos están implementadas; las actuaciones sencillas admiten registro sin plantilla.

View File

@ -46,3 +46,7 @@ Entradas y salidas estrictas. Escrituras protegidas por sesión, CSRF y origen.
## Validación
Pruebas de dominio/mapper, filtros y renderizado de formularios, e integración HTTP/MariaDB: pertenencia, campos opcionales, afectados únicos, inactividad, referencias conservadas, UUID, referencia global, historial, idempotencia, conflictos concurrentes, autenticación, CSRF y origen. Las pruebas usan la base dedicada y no crean registros de prueba en desarrollo.
## Intervenciones relacionadas
Desde el listado se inicia una intervención para el ticket con asunto y afectados propuestos. La ficha consulta las intervenciones relacionadas. Varias intervenciones pueden corresponder al mismo ticket; finalizar una no cierra el ticket automáticamente. Véase [Intervenciones y plantillas](intervenciones-y-plantillas.md).

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/company.aggregate.test.ts src/api/domain/people.test.ts src/web/components/companies-page.test.tsx src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/api/domain/mailbox.test.ts src/web/access/access.test.tsx src/web/mail/mail.test.tsx src/api/domain/work-profile.test.ts src/web/profiles/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printers/printer.test.tsx src/api/domain/application-configuration.test.ts src/web/applications/application.test.tsx src/api/domain/certificate-configuration.test.ts src/web/certificates/certificate.test.tsx src/web/profiles/work-environment.test.tsx src/api/domain/telework-authorization.test.ts src/web/telework/telework.test.tsx src/api/domain/remote-connection.test.ts src/api/domain/process.test.ts src/web/remote/remote.test.tsx src/web/processes/processes.test.tsx src/api/domain/ticket.test.ts src/web/tickets/tickets.test.tsx"
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/components/companies-page.test.tsx src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/api/domain/mailbox.test.ts src/web/access/access.test.tsx src/web/mail/mail.test.tsx src/api/domain/work-profile.test.ts src/web/profiles/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printers/printer.test.tsx src/api/domain/application-configuration.test.ts src/web/applications/application.test.tsx src/api/domain/certificate-configuration.test.ts src/web/certificates/certificate.test.tsx src/web/profiles/work-environment.test.tsx src/api/domain/telework-authorization.test.ts src/web/telework/telework.test.tsx src/api/domain/remote-connection.test.ts src/api/domain/process.test.ts src/web/remote/remote.test.tsx src/web/processes/processes.test.tsx src/api/domain/ticket.test.ts src/web/tickets/tickets.test.tsx src/api/domain/intervention-template.test.ts src/web/intervention-templates/templates.test.tsx src/api/domain/intervention.test.ts src/web/interventions/interventions.test.tsx"
},
"dependencies": {
"@repo/rdx-ddd": "workspace:*",

View File

@ -0,0 +1,42 @@
import { Result } from "@repo/rdx-utils";
import type { Intervention } from "../domain/intervention.aggregate";
import { InterventionError, type InterventionInput } from "../domain/intervention-values";
import { InterventionValues } from "../domain/value-objects/intervention-values";
import type { InterventionSaveContext } from "./intervention.repository";
import { targetOptions } from "./ticket.repository";
export function mapInterventionInput(
input: InterventionInput,
context: InterventionSaveContext,
previous?: Intervention
) {
const values = InterventionValues.create(input);
if (!values.isSuccess) {
return values;
}
const next = values.data.toPrimitive();
const current = previous?.values.toPrimitive();
if (
next.ticketId &&
!context.tickets.some((ticket) => ticket.id.toPrimitive() === next.ticketId)
) {
return Result.fail(new InterventionError("TICKET_NOT_FOUND"));
}
if (next.assigneeId) {
const user = context.technicians.find((item) => item.id.toPrimitive() === next.assigneeId);
if (!user || (!user.active && next.assigneeId !== current?.assigneeId)) {
return Result.fail(new InterventionError("ASSIGNEE_UNAVAILABLE"));
}
}
for (const target of values.data.affected) {
const option = targetOptions(context.lookups, target.kind).find((item) =>
item.id.equals(target.targetId)
);
const retained = previous?.values.affected.some((item) => item.equals(target));
if (!(option && (option.active || retained))) {
return Result.fail(new InterventionError("TARGET_UNAVAILABLE"));
}
}
return values;
}

View File

@ -0,0 +1,58 @@
import type {
InterventionDTO,
InterventionListDTO,
InterventionLookupsDTO,
} from "../../common/interventions";
import type { Intervention } from "../domain/intervention.aggregate";
import type { InterventionContext, InterventionLookups } from "./intervention.repository";
import { buildInterventionTemplate } from "./intervention-template-response.builder";
import { buildTicket, buildTicketLookups } from "./ticket-response.builder";
export function buildIntervention(
intervention: Intervention,
context: InterventionContext
): InterventionDTO {
const values = intervention.values.toPrimitive();
return {
id: intervention.id.toPrimitive(),
companyId: intervention.companyId.toPrimitive(),
companyName:
context.companies.find((item) => item.id.equals(intervention.companyId))?.name ??
intervention.companyId.toPrimitive(),
reference: intervention.reference.toPrimitive(),
values,
assigneeName:
context.technicians.find((item) => item.id.toPrimitive() === values.assigneeId)?.name ?? null,
ticketReference:
context.tickets
.find((item) => item.id.toPrimitive() === values.ticketId)
?.reference.toPrimitive() ?? null,
procedure: intervention.procedure.getOrUndefined()?.toPrimitive() ?? null,
history: intervention.history.map((item) => ({
id: item.id.toPrimitive(),
values: item.values.toPrimitive(),
actorId: item.actorId.toPrimitive(),
actorName: context.actorNames.get(item.actorId.toPrimitive()) ?? item.actorId.toPrimitive(),
recordedAt: item.recordedAt,
})),
};
}
export function buildInterventions(context: InterventionContext): InterventionListDTO {
const option = (item: { id: { toPrimitive: () => string }; name: string; active: boolean }) => ({
id: item.id.toPrimitive(),
name: item.name,
active: item.active,
});
return {
interventions: context.interventions.map((item) => buildIntervention(item, context)),
companies: context.companies.map(option),
technicians: context.technicians.map(option),
};
}
export function buildInterventionLookups(data: InterventionLookups): InterventionLookupsDTO {
return {
...buildTicketLookups(data.lookups),
tickets: data.context.tickets.map((item) => buildTicket(item, data.context)),
templates: data.templates.map((item) => buildInterventionTemplate(item, data.templateActors)),
};
}

View File

@ -0,0 +1,22 @@
import type { InterventionTemplateDTO } from "../../common/intervention-templates";
import type { InterventionTemplate } from "../domain/intervention-template.aggregate";
export function buildInterventionTemplate(
template: InterventionTemplate,
actorNames: Map<string, string>
): InterventionTemplateDTO {
return {
id: template.id.toPrimitive(),
companyId: template.companyId.getOrUndefined()?.toPrimitive() ?? null,
values: template.values.toPrimitive(),
history: template.history.map((revision) => {
const actorId = revision.actorId.getOrUndefined()?.toPrimitive() ?? null;
return {
id: revision.id.toPrimitive(),
values: revision.values.toPrimitive(),
actorId,
actorName: actorId ? (actorNames.get(actorId) ?? actorId) : "Metis Desk",
recordedAt: revision.recordedAt,
};
}),
};
}

View File

@ -0,0 +1,25 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Result } from "@repo/rdx-utils";
import type { InterventionTemplate } from "../domain/intervention-template.aggregate";
import type { InterventionTemplateError } from "../domain/intervention-template-values";
export type InterventionTemplateContext = {
templates: InterventionTemplate[];
actorNames: Map<string, string>;
};
export interface IInterventionTemplateRepository {
list(
companyId: UniqueID | undefined
): Promise<Result<InterventionTemplateContext, InterventionTemplateError>>;
save(
companyId: UniqueID | undefined,
action: (
templates: InterventionTemplate[]
) => Result<InterventionTemplate, InterventionTemplateError>
): Promise<
Result<
InterventionTemplateContext & { template: InterventionTemplate },
InterventionTemplateError
>
>;
}

View File

@ -0,0 +1,134 @@
import { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import {
type SaveInterventionTemplateInput,
SaveInterventionTemplateSchema,
} from "../../common/intervention-templates";
import { InterventionTemplate } from "../domain/intervention-template.aggregate";
import { InterventionTemplateError } from "../domain/intervention-template-values";
import { InterventionTemplateValues } from "../domain/value-objects/intervention-template-values";
import type { IInterventionTemplateRepository } from "./intervention-template.repository";
export class InterventionTemplateUseCases {
constructor(private readonly repository: IInterventionTemplateRepository) {}
async list(scope: string) {
const company = scope === "global" ? undefined : UniqueID.create(scope);
if (company && !company.isSuccess) {
return Result.fail(new InterventionTemplateError("INVALID_REQUEST"));
}
return this.repository.list(company?.data);
}
async save(
scope: string,
id: string | undefined,
input: SaveInterventionTemplateInput,
actorId: string
) {
const parsed = SaveInterventionTemplateSchema.safeParse(input);
const company = scope === "global" ? undefined : UniqueID.create(scope);
const actor = UniqueID.create(actorId);
const templateId = id === undefined ? undefined : UniqueID.create(id);
if (
!parsed.success ||
(company && !company.isSuccess) ||
!actor.isSuccess ||
(templateId && !templateId.isSuccess)
) {
return Result.fail(new InterventionTemplateError("INVALID_REQUEST"));
}
const values = InterventionTemplateValues.create(parsed.data.values);
const key = UniqueID.create(parsed.data.requestKey);
if (!(values.isSuccess && key.isSuccess) || (!id && parsed.data.expectedRevisionId !== null)) {
return Result.fail(new InterventionTemplateError("INVALID_REQUEST"));
}
const scopeId = company?.data;
return this.repository.save(scopeId, (templates) => {
const previous = findScopedTemplate(templates, templateId?.data, scopeId);
if (id) {
if (!previous) {
return Result.fail(new InterventionTemplateError("TEMPLATE_NOT_FOUND"));
}
if (
previous.history.at(-1)?.id.toPrimitive() !==
parsed.data.expectedRevisionId?.toLowerCase()
) {
return Result.fail(new InterventionTemplateError("TEMPLATE_CHANGED"));
}
} else {
const replay = templates.find((item) => item.requestKey.equals(key.data));
if (replay) {
return replayTemplate(replay, scopeId, values.data, actor.data);
}
}
if (hasDuplicateName(templates, scopeId, previous, values.data)) {
return Result.fail(new InterventionTemplateError("TEMPLATE_NAME_EXISTS"));
}
const now = new Date().toISOString();
return previous
? previous.update(values.data, actor.data, now)
: createTemplate(scopeId, key.data, values.data, actor.data, now);
});
}
}
function replayTemplate(
template: InterventionTemplate,
companyId: UniqueID | undefined,
values: InterventionTemplateValues,
actorId: UniqueID
): Result<InterventionTemplate, InterventionTemplateError> {
const original = template.history[0];
if (
template.companyId.getOrUndefined()?.toPrimitive() !== companyId?.toPrimitive() ||
!original?.values.equals(values) ||
original.actorId.getOrUndefined()?.toPrimitive() !== actorId.toPrimitive()
) {
return Result.fail(new InterventionTemplateError("REQUEST_KEY_REUSED"));
}
return Result.ok(template);
}
function hasDuplicateName(
templates: InterventionTemplate[],
companyId: UniqueID | undefined,
previous: InterventionTemplate | undefined,
values: InterventionTemplateValues
) {
const name = values.name.toPrimitive().toLocaleLowerCase();
return templates.some(
(item) =>
item.id.toPrimitive() !== previous?.id.toPrimitive() &&
item.companyId.getOrUndefined()?.toPrimitive() === companyId?.toPrimitive() &&
item.values.name.toPrimitive().toLocaleLowerCase() === name
);
}
function findScopedTemplate(
templates: InterventionTemplate[],
id: UniqueID | undefined,
companyId: UniqueID | undefined
) {
return templates.find(
(item) =>
item.id.toPrimitive() === id?.toPrimitive() &&
item.companyId.getOrUndefined()?.toPrimitive() === companyId?.toPrimitive()
);
}
function createTemplate(
companyId: UniqueID | undefined,
key: UniqueID,
values: InterventionTemplateValues,
actorId: UniqueID,
recordedAt: string
) {
return InterventionTemplate.create(
companyId ? Maybe.some(companyId) : Maybe.none(),
key,
values,
Maybe.some(actorId),
recordedAt
);
}

View File

@ -0,0 +1,28 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Result } from "@repo/rdx-utils";
import type { Intervention } from "../domain/intervention.aggregate";
import type { InterventionTemplate } from "../domain/intervention-template.aggregate";
import type { InterventionError } from "../domain/intervention-values";
import type { TicketContext, TicketLookups } from "./ticket.repository";
export type InterventionContext = TicketContext & { interventions: readonly Intervention[] };
export type InterventionLookups = {
context: TicketContext;
lookups: TicketLookups;
templates: readonly InterventionTemplate[];
templateActors: Map<string, string>;
};
export type InterventionSaveContext = InterventionContext &
InterventionLookups & { nextNumber: number; requestIntervention: Intervention | undefined };
export interface IInterventionRepository {
list(company?: UniqueID): Promise<Result<InterventionContext, InterventionError>>;
lookups(company: UniqueID): Promise<Result<InterventionLookups, InterventionError>>;
save(
company: UniqueID,
requestKey: UniqueID,
action: (context: InterventionSaveContext) => Result<Intervention, InterventionError>
): Promise<
Result<{ intervention: Intervention; context: InterventionContext }, InterventionError>
>;
}

View File

@ -0,0 +1,148 @@
import { Name, UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { SaveInterventionInput } from "../../common/interventions";
import { SaveInterventionSchema } from "../../common/interventions";
import { Intervention } from "../domain/intervention.aggregate";
import { InterventionError } from "../domain/intervention-values";
import { InterventionProcedure } from "../domain/value-objects/intervention-procedure";
import { InterventionValues } from "../domain/value-objects/intervention-values";
import type { IInterventionRepository, InterventionSaveContext } from "./intervention.repository";
import { mapInterventionInput } from "./intervention-input.mapper";
export class InterventionUseCases {
constructor(private readonly repository: IInterventionRepository) {}
list(companyId?: string) {
const company = companyId ? UniqueID.create(companyId) : undefined;
if (company && !company.isSuccess) {
return Promise.resolve(Result.fail(new InterventionError("INVALID_REQUEST")));
}
return this.repository.list(company?.isSuccess ? company.data : undefined);
}
lookups(companyId: string) {
const company = UniqueID.create(companyId);
return company.isSuccess
? this.repository.lookups(company.data)
: Promise.resolve(Result.fail(new InterventionError("INVALID_REQUEST")));
}
save(companyId: string, id: string | undefined, input: SaveInterventionInput, actorId: string) {
const parsed = SaveInterventionSchema.safeParse(input);
if (!parsed.success || (id && input.template !== null)) {
return Promise.resolve(Result.fail(new InterventionError("INVALID_REQUEST")));
}
const company = UniqueID.create(companyId);
const actor = UniqueID.create(actorId);
const key = UniqueID.create(input.requestKey);
const identity = id ? UniqueID.create(id) : undefined;
if (
!(company.isSuccess && actor.isSuccess && key.isSuccess) ||
(identity && !identity.isSuccess) ||
(!id && input.expectedRevisionId !== null)
) {
return Promise.resolve(Result.fail(new InterventionError("INVALID_REQUEST")));
}
const normalizedId = identity?.isSuccess ? identity.data.toPrimitive() : undefined;
return this.repository.save(company.data, key.data, (context) => {
const previous = context.interventions.find((item) => item.id.toPrimitive() === normalizedId);
if (id && !previous) {
return Result.fail(new InterventionError("INTERVENTION_NOT_FOUND"));
}
if (!id && context.requestIntervention) {
return replayIntervention(context.requestIntervention, company.data, actor.data, input);
}
if (previous && previous.history.at(-1)?.id.toPrimitive() !== input.expectedRevisionId) {
return Result.fail(new InterventionError("INTERVENTION_CHANGED"));
}
const values = mapInterventionInput(input.values, context, previous);
if (!values.isSuccess) {
return Result.fail(values.error);
}
const now = new Date().toISOString();
if (previous) {
return previous.update(values.data, actor.data, now);
}
const procedure = appliedProcedure(input, context);
if (!procedure.isSuccess) {
return Result.fail(procedure.error);
}
return createIntervention(
company.data,
key.data,
values.data,
actor.data,
context.nextNumber,
now,
procedure.data
);
});
}
}
function replayIntervention(
original: Intervention,
company: UniqueID,
actor: UniqueID,
input: SaveInterventionInput
): Result<Intervention, InterventionError> {
const values = InterventionValues.create(input.values);
const first = original.history[0];
if (
!(
sameProcedure(original, input) &&
original.companyId.equals(company) &&
first?.actorId.equals(actor) &&
values.isSuccess &&
first.values.equals(values.data)
)
) {
return Result.fail(new InterventionError("REQUEST_KEY_REUSED"));
}
return Result.ok(original);
}
function createIntervention(
company: UniqueID,
key: UniqueID,
values: InterventionValues,
actor: UniqueID,
number: number,
now: string,
procedure: Maybe<InterventionProcedure>
): Result<Intervention, InterventionError> {
const reference = Name.create(`I-${String(number).padStart(6, "0")}`, 40);
if (!reference.isSuccess) {
return Result.fail(new InterventionError("INVALID_REQUEST"));
}
return Intervention.create(company, reference.data, key, values, procedure, actor, now);
}
function sameProcedure(intervention: Intervention, input: SaveInterventionInput) {
const source = intervention.procedure.getOrUndefined();
return source
? source.templateId.toPrimitive() === input.template?.id.toLowerCase() &&
source.revisionId.toPrimitive() === input.template.revisionId.toLowerCase()
: input.template === null;
}
function appliedProcedure(
input: SaveInterventionInput,
context: InterventionSaveContext
): Result<Maybe<InterventionProcedure>, InterventionError> {
if (!input.template) {
return Result.ok(Maybe.none());
}
const template = context.templates.find(
(item) => item.id.toPrimitive() === input.template?.id.toLowerCase()
);
if (!(template && template.values.active)) {
return Result.fail(new InterventionError("TEMPLATE_UNAVAILABLE"));
}
if (template.history.at(-1)?.id.toPrimitive() !== input.template.revisionId.toLowerCase()) {
return Result.fail(new InterventionError("TEMPLATE_CHANGED"));
}
const procedure = InterventionProcedure.create({
templateId: template.id.toPrimitive(),
revisionId: input.template.revisionId,
values: template.values.toPrimitive(),
});
return procedure.isSuccess ? Result.ok(Maybe.some(procedure.data)) : Result.fail(procedure.error);
}

View File

@ -0,0 +1,25 @@
import { DomainEntity, type UniqueID } from "@repo/rdx-ddd";
import type { InterventionValues } from "./value-objects/intervention-values";
export type InterventionRevisionProps = Readonly<{
values: InterventionValues;
actorId: UniqueID;
recordedAt: string;
}>;
export class InterventionRevision extends DomainEntity<InterventionRevisionProps> {
static create(props: InterventionRevisionProps) {
return new InterventionRevision(Object.freeze({ ...props }));
}
static rehydrate(props: InterventionRevisionProps, id: UniqueID) {
return new InterventionRevision(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,56 @@
import { DomainEntity, Name, UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import {
type InterventionCheckInput,
InterventionError,
InterventionStepSchema,
} from "./intervention-values";
type Props = Readonly<{
title: Name;
instructions: string;
required: boolean;
state: InterventionCheckInput["state"];
notes: string;
}>;
export class InterventionStep extends DomainEntity<Props> {
private constructor(props: Props, id: UniqueID) {
super(Object.freeze({ ...props }), id);
}
static create(input: InterventionCheckInput): Result<InterventionStep, InterventionError> {
const parsed = InterventionStepSchema.safeParse(input);
if (!parsed.success) {
return Result.fail(new InterventionError("INVALID_REQUEST"));
}
const id = UniqueID.create(parsed.data.id);
const title = Name.create(parsed.data.title, 200);
if (!(id.isSuccess && title.isSuccess)) {
return Result.fail(new InterventionError("INVALID_REQUEST"));
}
return Result.ok(new InterventionStep({ ...parsed.data, title: title.data }, id.data));
}
static rehydrate(props: Props, id: UniqueID) {
return new InterventionStep(props, id);
}
toPrimitive(): InterventionCheckInput {
return {
id: this.id.toPrimitive(),
title: this.props.title.toPrimitive(),
instructions: this.props.instructions,
required: this.props.required,
state: this.props.state,
notes: this.props.notes,
};
}
equals(other: InterventionStep) {
return (
this.id.equals(other.id) &&
this.props.title.equals(other.props.title) &&
this.props.instructions === other.props.instructions &&
this.props.required === other.props.required &&
this.props.state === other.props.state &&
this.props.notes === other.props.notes
);
}
}

View File

@ -0,0 +1,27 @@
import { DomainEntity, type UniqueID } from "@repo/rdx-ddd";
import type { Maybe } from "@repo/rdx-utils";
import type { InterventionTemplateValues } from "./value-objects/intervention-template-values";
type Props = Readonly<{
values: InterventionTemplateValues;
actorId: Maybe<UniqueID>;
recordedAt: string;
}>;
export class InterventionTemplateRevision extends DomainEntity<Props> {
static create(props: Props) {
return new InterventionTemplateRevision(Object.freeze({ ...props }));
}
static rehydrate(props: Props, id: UniqueID) {
return new InterventionTemplateRevision(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,101 @@
import { UniqueID } from "@repo/rdx-ddd";
import { Maybe } from "@repo/rdx-utils";
import { InterventionTemplate } from "./intervention-template.aggregate";
import { InterventionTemplateValues } from "./value-objects/intervention-template-values";
const definitions = [
[
"Alta de persona",
[
"Revisar las necesidades y el perfil de usuario",
"Asignar y preparar los equipos",
"Configurar cuentas y accesos autorizados",
"Configurar correos, aplicaciones e impresoras",
"Comprobar el funcionamiento con la persona",
],
],
[
"Baja de persona",
[
"Revisar cuentas, recursos y dispositivos utilizados",
"Retirar accesos, incluidos los configurados en dispositivos externos",
"Preservar o transferir los datos según las indicaciones de la compañía",
"Recuperar activos y cerrar asignaciones",
"Comprobar que no quedan accesos pendientes",
],
],
[
"Sustitución de equipo",
[
"Revisar el perfil de usuario y sus necesidades",
"Comprobar la copia de datos del equipo anterior",
"Configurar el inicio de sesión",
"Configurar correos, aplicaciones, carpetas e impresoras",
"Revisar certificados y conectividad remota",
"Validar con la persona y actualizar asignaciones",
],
],
[
"Configuración de correo",
[
"Confirmar buzones y autorizaciones",
"Configurar cada cuenta y su método de acceso",
"Comprobar envío y recepción",
"Revisar carpetas y conservación de mensajes",
"Actualizar la configuración registrada",
],
],
[
"Preparación de portátil para teletrabajo",
[
"Comprobar la autorización y el equipo",
"Revisar actualizaciones y protección del equipo",
"Configurar la conectividad remota necesaria",
"Probar los recursos autorizados desde el exterior",
"Actualizar la documentación del perfil",
],
],
[
"Revisión periódica de equipo",
[
"Revisar actualizaciones y antivirus",
"Comprobar almacenamiento y estado general",
"Revisar copias de seguridad y cifrado",
"Comprobar aplicaciones y periféricos",
"Registrar incidencias y trabajo realizado",
],
],
] as const;
/** Las claves estables hacen que un reinicio no reponga plantillas editadas o desactivadas. */
export function interventionTemplateSeeds() {
return definitions.map(([name, titles], index) => {
const key = UniqueID.create(`019a0000-0000-7000-8000-${String(index + 1).padStart(12, "0")}`);
const values = InterventionTemplateValues.create({
name,
description:
"Procedimiento orientativo. Adapta los pasos a las necesidades y autorizaciones de la compañía.",
active: true,
steps: titles.map((title) => ({
id: UniqueID.generateNewID().toPrimitive(),
title,
instructions: "",
required: false,
})),
});
if (!(key.isSuccess && values.isSuccess)) {
throw new Error("Invalid intervention template seed");
}
const result = InterventionTemplate.create(
Maybe.none(),
key.data,
values.data,
Maybe.none(),
"2026-10-07T00:00:00.000Z"
);
if (!result.isSuccess) {
throw result.error;
}
return result.data;
});
}

View File

@ -0,0 +1,57 @@
import { DomainEntity, Name, UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import {
type InterventionStepInput,
InterventionStepInputSchema,
InterventionTemplateError,
} from "./intervention-template-values";
type Props = Readonly<{ title: Name; instructions: string; required: boolean }>;
export class InterventionTemplateStep extends DomainEntity<Props> {
private constructor(props: Props, id: UniqueID) {
super(Object.freeze({ ...props }), id);
}
static create(
input: InterventionStepInput
): Result<InterventionTemplateStep, InterventionTemplateError> {
const parsed = InterventionStepInputSchema.safeParse(input);
if (!parsed.success) {
return Result.fail(new InterventionTemplateError("INVALID_REQUEST"));
}
const title = Name.create(parsed.data.title, 200);
const id = UniqueID.create(parsed.data.id);
if (!(title.isSuccess && id.isSuccess)) {
return Result.fail(new InterventionTemplateError("INVALID_REQUEST"));
}
return Result.ok(
new InterventionTemplateStep(
{
title: title.data,
instructions: parsed.data.instructions,
required: parsed.data.required,
},
id.data
)
);
}
static rehydrate(props: Props, id: UniqueID) {
return new InterventionTemplateStep(props, id);
}
equals(other: InterventionTemplateStep) {
return (
this.id.equals(other.id) &&
this.props.title.equals(other.props.title) &&
this.props.instructions === other.props.instructions &&
this.props.required === other.props.required
);
}
toPrimitive(): InterventionStepInput {
return {
id: this.id.toPrimitive(),
title: this.props.title.toPrimitive(),
instructions: this.props.instructions,
required: this.props.required,
};
}
}

View File

@ -0,0 +1,25 @@
import { z } from "zod/v4";
export class InterventionTemplateError extends Error {
constructor(public readonly code: string) {
super(code);
}
}
export const InterventionStepInputSchema = z.strictObject({
id: z.uuid(),
title: z.string().trim().min(1).max(200),
instructions: z.string().trim().max(4000),
required: z.boolean(),
});
export const InterventionTemplateInputSchema = z.strictObject({
name: z.string().trim().min(1).max(160),
description: z.string().trim().max(4000),
active: z.boolean(),
steps: z
.array(InterventionStepInputSchema)
.min(1)
.max(100)
.refine((steps) => new Set(steps.map((step) => step.id.toLowerCase())).size === steps.length),
});
export type InterventionStepInput = z.infer<typeof InterventionStepInputSchema>;
export type InterventionTemplateInput = z.infer<typeof InterventionTemplateInputSchema>;

View File

@ -0,0 +1,85 @@
import { AggregateRoot, type UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { z } from "zod/v4";
import { InterventionTemplateRevision } from "./intervention-template-revision.entity";
import { InterventionTemplateError } from "./intervention-template-values";
import type { InterventionTemplateValues } from "./value-objects/intervention-template-values";
type Props = Readonly<{
companyId: Maybe<UniqueID>;
requestKey: UniqueID;
values: InterventionTemplateValues;
history: readonly InterventionTemplateRevision[];
}>;
export class InterventionTemplate extends AggregateRoot<Props> {
private constructor(props: Props, id?: UniqueID) {
super(Object.freeze({ ...props, history: Object.freeze([...props.history]) }), id);
}
static create(
companyId: Maybe<UniqueID>,
requestKey: UniqueID,
values: InterventionTemplateValues,
actorId: Maybe<UniqueID>,
recordedAt: string
): Result<InterventionTemplate, InterventionTemplateError> {
if (!z.iso.datetime().safeParse(recordedAt).success) {
return Result.fail(new InterventionTemplateError("INVALID_REQUEST"));
}
return Result.ok(
new InterventionTemplate({
companyId,
requestKey,
values,
history: [InterventionTemplateRevision.create({ values, actorId, recordedAt })],
})
);
}
static rehydrate(props: Props, id: UniqueID) {
return new InterventionTemplate(props, id);
}
get companyId() {
return this.props.companyId;
}
get requestKey() {
return this.props.requestKey;
}
get values() {
return this.props.values;
}
get history() {
return this.props.history;
}
update(
values: InterventionTemplateValues,
actorId: UniqueID,
recordedAt: string
): Result<InterventionTemplate, InterventionTemplateError> {
if (
!z.iso.datetime().safeParse(recordedAt).success ||
recordedAt < (this.history.at(-1)?.recordedAt ?? "")
) {
return Result.fail(new InterventionTemplateError("INVALID_REQUEST"));
}
if (this.values.equals(values)) {
return Result.ok(this);
}
return Result.ok(
new InterventionTemplate(
{
...this.props,
values,
history: [
...this.history,
InterventionTemplateRevision.create({
values,
actorId: Maybe.some(actorId),
recordedAt,
}),
],
},
this.id
)
);
}
}

View File

@ -0,0 +1,172 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { UniqueID } from "@repo/rdx-ddd";
import { Maybe } from "@repo/rdx-utils";
import { SaveInterventionTemplateSchema } from "../../common/intervention-templates";
import { InterventionTemplatePersistenceMapper } from "../infrastructure/persistence/intervention-template-persistence.mapper";
import { InterventionTemplate } from "./intervention-template.aggregate";
import { interventionTemplateSeeds } from "./intervention-template-seed";
import {
type InterventionTemplateInput,
InterventionTemplateInputSchema,
} from "./intervention-template-values";
import { InterventionTemplateValues } from "./value-objects/intervention-template-values";
const actor = UniqueID.generateNewID();
const date = "2026-10-07T10:00:00.000Z";
function input(): InterventionTemplateInput {
return {
name: " Preparar equipo ",
description: "",
active: true,
steps: [
{
id: UniqueID.generateNewID().toPrimitive(),
title: " Copiar datos ",
instructions: "",
required: false,
},
{
id: UniqueID.generateNewID().toPrimitive(),
title: "Comprobar correo",
instructions: "Probar todas las cuentas",
required: true,
},
],
};
}
function values(data = input()) {
const result = InterventionTemplateValues.create(data);
assert.ok(result.isSuccess);
return result.data;
}
function template() {
const result = InterventionTemplate.create(
Maybe.none(),
UniqueID.generateNewID(),
values(),
Maybe.some(actor),
date
);
assert.ok(result.isSuccess);
return result.data;
}
test("plantillas de intervención: límites, pasos únicos y entrada estricta", () => {
const data = input();
assert.equal(InterventionTemplateInputSchema.parse(data).name, "Preparar equipo");
for (const invalid of [
{ ...data, name: "" },
{ ...data, name: "x".repeat(161) },
{ ...data, steps: [] },
{ ...data, steps: [data.steps[0], data.steps[0]] },
{ ...data, unknown: true },
]) {
assert.equal(InterventionTemplateInputSchema.safeParse(invalid).success, false);
}
assert.equal(
InterventionTemplateInputSchema.safeParse({
...data,
steps: Array.from({ length: 100 }, () => ({
id: UniqueID.generateNewID().toPrimitive(),
title: "Paso",
instructions: "",
required: false,
})),
}).success,
true
);
assert.equal(
InterventionTemplateInputSchema.safeParse({
...data,
steps: Array.from({ length: 101 }, () => ({
id: UniqueID.generateNewID().toPrimitive(),
title: "Paso",
instructions: "",
required: false,
})),
}).success,
false
);
assert.equal(
SaveInterventionTemplateSchema.safeParse({
values: data,
requestKey: UniqueID.generateNewID().toPrimitive(),
expectedRevisionId: null,
extra: 1,
}).success,
false
);
});
test("plantillas de intervención: identidad v7, no-op, orden e historial inmutable", () => {
const original = template();
assert.equal(original.id.toPrimitive()[14], "7");
assert.equal(original.history[0]?.id.toPrimitive()[14], "7");
assert.ok(original.values.steps.every((step) => step.id.toPrimitive()[14] === "7"));
const same = original.update(values(original.values.toPrimitive()), actor, date);
assert.ok(same.isSuccess);
assert.equal(same.data, original);
const changed = original.update(
values({
...original.values.toPrimitive(),
active: false,
steps: [...original.values.toPrimitive().steps].reverse(),
}),
actor,
"2026-10-07T11:00:00.000Z"
);
assert.ok(changed.isSuccess);
assert.equal(changed.data.id.toPrimitive(), original.id.toPrimitive());
assert.equal(changed.data.history.length, 2);
assert.equal(original.history.length, 1);
assert.equal(changed.data.history[0]?.values.active, true);
assert.equal(changed.data.history[1]?.values.active, false);
assert.equal(original.update(original.values, actor, "invalid").isSuccess, false);
assert.equal(
original.update(original.values, actor, "2026-10-06T00:00:00.000Z").isSuccess,
false
);
});
test("plantillas de intervención: mapper conserva pasos, revisiones y ámbito", () => {
const original = template();
const mapper = new InterventionTemplatePersistenceMapper();
const persisted = mapper.toPersistence(original);
const rehydrated = mapper.toDomain(persisted.root, persisted.revisions);
assert.ok(rehydrated.values.equals(original.values));
assert.equal(rehydrated.history[0]?.id.toPrimitive(), original.history[0]?.id.toPrimitive());
assert.throws(() => mapper.toDomain(persisted.root, []));
assert.throws(() =>
mapper.toDomain(
{
...persisted.root,
values: JSON.stringify({ ...original.values.toPrimitive(), active: false }),
},
persisted.revisions
)
);
assert.throws(() =>
mapper.toDomain(
persisted.root,
persisted.revisions.map((revision) => ({ ...revision, position: 2 }))
)
);
});
test("plantillas de intervención: seis procedimientos iniciales sin datos de compañías", () => {
const seeds = interventionTemplateSeeds();
const again = interventionTemplateSeeds();
assert.equal(seeds.length, 6);
assert.deepEqual(
seeds.map((item) => item.requestKey.toPrimitive()),
again.map((item) => item.requestKey.toPrimitive())
);
assert.ok(
seeds.every(
(item) =>
item.companyId.getOrUndefined() === undefined &&
item.history[0]?.actorId.getOrUndefined() === undefined &&
item.values.active
)
);
});

View File

@ -0,0 +1,73 @@
import { z } from "zod/v4";
import { TicketTargetSchema } from "./ticket-values";
export const InterventionStateSchema = z.enum(["pending", "in_progress", "finished", "cancelled"]);
export const InterventionModalitySchema = z.enum(["remote", "onsite", "other"]);
export const InterventionStepStateSchema = z.enum([
"pending",
"in_progress",
"done",
"not_applicable",
]);
export const InterventionStepSchema = z.strictObject({
id: z.uuid(),
title: z.string().trim().min(1).max(200),
instructions: z.string().trim().max(4000),
required: z.boolean(),
state: InterventionStepStateSchema,
notes: z.string().trim().max(4000),
});
export const InterventionInputSchema = z
.strictObject({
subject: z.string().trim().min(1).max(200),
ticketId: z.uuid().nullable(),
assigneeId: z.uuid().nullable(),
state: InterventionStateSchema,
modality: InterventionModalitySchema,
plannedDate: z.iso.date().nullable(),
startedAt: z.iso.datetime().nullable(),
endedAt: z.iso.datetime().nullable(),
minutes: z.number().int().min(0).max(1000000).nullable(),
diagnosis: z.string().trim().max(10000),
workPerformed: z.string().trim().max(10000),
resolution: z.string().trim().max(10000),
affected: z.array(TicketTargetSchema).max(200),
steps: z.array(InterventionStepSchema).max(100),
})
.superRefine((value, ctx) => {
if (
new Set(value.affected.map((item) => `${item.kind}:${item.targetId.toLowerCase()}`)).size !==
value.affected.length
) {
ctx.addIssue({ code: "custom", path: ["affected"], message: "Duplicate targets" });
}
if (new Set(value.steps.map((item) => item.id.toLowerCase())).size !== value.steps.length) {
ctx.addIssue({ code: "custom", path: ["steps"], message: "Duplicate steps" });
}
if (value.startedAt && value.endedAt && value.endedAt < value.startedAt) {
ctx.addIssue({ code: "custom", path: ["endedAt"], message: "End before start" });
}
if ((value.state === "in_progress" || value.state === "finished") && !value.assigneeId) {
ctx.addIssue({ code: "custom", path: ["assigneeId"], message: "Technician required" });
}
if (value.state === "finished" && !value.workPerformed) {
ctx.addIssue({ code: "custom", path: ["workPerformed"], message: "Work required" });
}
if (
value.state === "finished" &&
value.steps.some(
(step) =>
step.required &&
!(step.state === "done" || (step.state === "not_applicable" && step.notes))
)
) {
ctx.addIssue({ code: "custom", path: ["steps"], message: "Required checks pending" });
}
});
export type InterventionInput = z.infer<typeof InterventionInputSchema>;
export type InterventionCheckInput = z.infer<typeof InterventionStepSchema>;
export class InterventionError extends Error {
constructor(public readonly code: string) {
super(code);
}
}

View File

@ -0,0 +1,91 @@
import { AggregateRoot, type Name, type UniqueID } from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils";
import { z } from "zod/v4";
import { InterventionRevision } from "./intervention-revision.entity";
import { InterventionError } from "./intervention-values";
import type { InterventionProcedure } from "./value-objects/intervention-procedure";
import type { InterventionValues } from "./value-objects/intervention-values";
type InterventionProps = Readonly<{
companyId: UniqueID;
procedure: Maybe<InterventionProcedure>;
reference: Name;
requestKey: UniqueID;
values: InterventionValues;
history: readonly InterventionRevision[];
}>;
export class Intervention extends AggregateRoot<InterventionProps> {
private constructor(props: InterventionProps, id?: UniqueID) {
super(Object.freeze({ ...props, history: Object.freeze([...props.history]) }), id);
}
static create(
companyId: UniqueID,
reference: Name,
requestKey: UniqueID,
values: InterventionValues,
procedure: Maybe<InterventionProcedure>,
actorId: UniqueID,
recordedAt: string
): Result<Intervention, InterventionError> {
if (!z.iso.datetime().safeParse(recordedAt).success) {
return Result.fail(new InterventionError("INVALID_REQUEST"));
}
return Result.ok(
new Intervention({
companyId,
procedure,
reference,
requestKey,
values,
history: [InterventionRevision.create({ values, actorId, recordedAt })],
})
);
}
static rehydrate(props: InterventionProps, id: UniqueID) {
return new Intervention(props, id);
}
get procedure() {
return this.props.procedure;
}
get reference() {
return this.props.reference;
}
get requestKey() {
return this.props.requestKey;
}
get companyId() {
return this.props.companyId;
}
get values() {
return this.props.values;
}
get history() {
return this.props.history;
}
update(
values: InterventionValues,
actorId: UniqueID,
recordedAt: string
): Result<Intervention, InterventionError> {
if (
!z.iso.datetime().safeParse(recordedAt).success ||
recordedAt < (this.history.at(-1)?.recordedAt ?? "")
) {
return Result.fail(new InterventionError("INVALID_REQUEST"));
}
if (this.values.equals(values)) {
return Result.ok(this);
}
return Result.ok(
new Intervention(
{
...this.props,
values,
history: [...this.history, InterventionRevision.create({ values, actorId, recordedAt })],
},
this.id
)
);
}
}

View File

@ -0,0 +1,213 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { Name, UniqueID } from "@repo/rdx-ddd";
import { Maybe } from "@repo/rdx-utils";
import { InterventionPersistenceMapper } from "../infrastructure/persistence/intervention-persistence.mapper";
import { Intervention } from "./intervention.aggregate";
import { type InterventionInput, InterventionInputSchema } from "./intervention-values";
import { InterventionProcedure } from "./value-objects/intervention-procedure";
import { InterventionValues } from "./value-objects/intervention-values";
const actor = UniqueID.generateNewID();
function input(): InterventionInput {
return {
subject: "Revisar impresora",
ticketId: null,
assigneeId: null,
state: "pending",
modality: "onsite",
plannedDate: null,
startedAt: null,
endedAt: null,
minutes: null,
diagnosis: "",
workPerformed: "",
resolution: "",
affected: [],
steps: [],
};
}
function values(data = input()) {
const result = InterventionValues.create(data);
assert.ok(result.isSuccess);
return result.data;
}
function intervention() {
const name = Name.create("I-000001", 40);
assert.ok(name.isSuccess);
const created = Intervention.create(
UniqueID.generateNewID(),
name.data,
UniqueID.generateNewID(),
values(),
Maybe.none(),
actor,
"2026-10-07T10:00:00.000Z"
);
assert.ok(created.isSuccess);
return created.data;
}
test("intervenciones: actuación sencilla y finalización mínima", () => {
assert.equal(InterventionInputSchema.safeParse(input()).success, true);
const done = {
...input(),
state: "finished",
assigneeId: actor.toPrimitive(),
workPerformed: "Atasco resuelto",
};
assert.equal(InterventionInputSchema.safeParse(done).success, true);
assert.equal(InterventionInputSchema.safeParse({ ...done, assigneeId: null }).success, false);
assert.equal(InterventionInputSchema.safeParse({ ...done, workPerformed: " " }).success, false);
assert.equal(
InterventionInputSchema.safeParse({ ...input(), state: "in_progress" }).success,
false
);
assert.equal(values().toPrimitive().minutes, null);
});
test("intervenciones: comprobaciones obligatorias y no aplica justificado", () => {
const step = {
id: UniqueID.generateNewID().toPrimitive(),
title: "Comprobar copia",
instructions: "",
required: true,
state: "pending",
notes: "",
};
const done = {
...input(),
state: "finished",
assigneeId: actor.toPrimitive(),
workPerformed: "Revisado",
steps: [step],
};
assert.equal(InterventionInputSchema.safeParse(done).success, false);
assert.equal(
InterventionInputSchema.safeParse({ ...done, steps: [{ ...step, state: "done" }] }).success,
true
);
assert.equal(
InterventionInputSchema.safeParse({ ...done, steps: [{ ...step, state: "not_applicable" }] })
.success,
false
);
assert.equal(
InterventionInputSchema.safeParse({
...done,
steps: [{ ...step, state: "not_applicable", notes: "No almacena datos" }],
}).success,
true
);
assert.equal(
InterventionInputSchema.safeParse({ ...done, steps: [{ ...step, required: false }] }).success,
true
);
});
test("intervenciones: límites, fechas, minutos y entrada estricta", () => {
for (const data of [
{ ...input(), minutes: -1 },
{ ...input(), minutes: 1.5 },
{ ...input(), extra: true },
{ ...input(), startedAt: "2026-10-07T12:00:00.000Z", endedAt: "2026-10-07T11:00:00.000Z" },
{ ...input(), subject: "x".repeat(201) },
]) {
assert.equal(InterventionInputSchema.safeParse(data).success, false);
}
assert.equal(InterventionInputSchema.safeParse({ ...input(), minutes: 0 }).success, true);
const step = {
id: UniqueID.generateNewID().toPrimitive(),
title: "Paso",
instructions: "",
required: false,
state: "pending",
notes: "",
};
assert.equal(
InterventionInputSchema.safeParse({ ...input(), steps: [step, step] }).success,
false
);
});
test("intervenciones: identidad, no-op e historial inmutable", () => {
const original = intervention();
assert.equal(original.id.toPrimitive()[14], "7");
const same = original.update(
values(original.values.toPrimitive()),
actor,
"2026-10-07T11:00:00.000Z"
);
assert.ok(same.isSuccess);
assert.equal(same.data, original);
const changed = original.update(
values({ ...input(), diagnosis: "Papel atascado" }),
actor,
"2026-10-07T11:00:00.000Z"
);
assert.ok(changed.isSuccess);
assert.equal(changed.data.history.length, 2);
assert.equal(original.history.length, 1);
assert.equal(changed.data.id.toPrimitive(), original.id.toPrimitive());
assert.equal(changed.data.history[0]?.values.toPrimitive().diagnosis, "");
assert.equal(
original.update(original.values, actor, "2026-10-06T00:00:00.000Z").isSuccess,
false
);
});
test("intervenciones: reconstrucción e historial coherente", () => {
const original = intervention();
const mapper = new InterventionPersistenceMapper();
const rows = mapper.toPersistence(original);
const restored = mapper.toDomain(rows.root, rows.revisions);
assert.ok(restored.values.equals(original.values));
assert.equal(restored.history[0]?.id.toPrimitive(), original.history[0]?.id.toPrimitive());
assert.throws(() => mapper.toDomain(rows.root, []));
assert.throws(() =>
mapper.toDomain({ ...rows.root, assigneeId: actor.toPrimitive() }, rows.revisions)
);
assert.throws(() =>
mapper.toDomain(
rows.root,
rows.revisions.map((row) => ({ ...row, position: 3 }))
)
);
});
test("intervenciones: procedimiento inmutable y versionado", () => {
const procedure = InterventionProcedure.create({
templateId: UniqueID.generateNewID().toPrimitive(),
revisionId: UniqueID.generateNewID().toPrimitive(),
values: {
name: "Preparar equipo",
description: "",
active: true,
steps: [
{
id: UniqueID.generateNewID().toPrimitive(),
title: "Comprobar",
instructions: "",
required: false,
},
],
},
});
assert.ok(procedure.isSuccess);
const original = intervention();
const snapshot = Intervention.rehydrate(
{
companyId: original.companyId,
requestKey: original.requestKey,
reference: original.reference,
values: original.values,
history: original.history,
procedure: Maybe.some(procedure.data),
},
original.id
);
const mapper = new InterventionPersistenceMapper();
const rows = mapper.toPersistence(snapshot);
const restored = mapper.toDomain(rows.root, rows.revisions);
assert.deepEqual(
restored.procedure.getOrUndefined()?.toPrimitive(),
procedure.data.toPrimitive()
);
});

View File

@ -0,0 +1,36 @@
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { InterventionTemplateInput } from "../intervention-template-values";
import { InterventionError } from "../intervention-values";
import { InterventionTemplateValues } from "./intervention-template-values";
export class InterventionProcedure {
private constructor(
public readonly templateId: UniqueID,
public readonly revisionId: UniqueID,
public readonly values: InterventionTemplateValues
) {
Object.freeze(this);
}
static create(input: {
templateId: string;
revisionId: string;
values: InterventionTemplateInput;
}): Result<InterventionProcedure, InterventionError> {
const template = UniqueID.create(input.templateId);
const revision = UniqueID.create(input.revisionId);
const values = InterventionTemplateValues.create(input.values);
if (!(template.isSuccess && revision.isSuccess && values.isSuccess)) {
return Result.fail(new InterventionError("INVALID_REQUEST"));
}
return Result.ok(new InterventionProcedure(template.data, revision.data, values.data));
}
toPrimitive() {
return {
templateId: this.templateId.toPrimitive(),
revisionId: this.revisionId.toPrimitive(),
values: this.values.toPrimitive(),
};
}
}

View File

@ -0,0 +1,68 @@
import { Name } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { InterventionTemplateStep } from "../intervention-template-step.entity";
import {
InterventionTemplateError,
type InterventionTemplateInput,
InterventionTemplateInputSchema,
} from "../intervention-template-values";
export class InterventionTemplateValues {
private constructor(
public readonly name: Name,
public readonly description: string,
public readonly active: boolean,
public readonly steps: readonly InterventionTemplateStep[]
) {
Object.freeze(this);
}
static create(
input: InterventionTemplateInput
): Result<InterventionTemplateValues, InterventionTemplateError> {
const parsed = InterventionTemplateInputSchema.safeParse(input);
if (!parsed.success) {
return Result.fail(new InterventionTemplateError("INVALID_REQUEST"));
}
const name = Name.create(parsed.data.name, 160);
if (!name.isSuccess) {
return Result.fail(new InterventionTemplateError("INVALID_REQUEST"));
}
const steps: InterventionTemplateStep[] = [];
for (const inputStep of parsed.data.steps) {
const step = InterventionTemplateStep.create(inputStep);
if (!step.isSuccess) {
return Result.fail(step.error);
}
steps.push(step.data);
}
return Result.ok(
new InterventionTemplateValues(
name.data,
parsed.data.description,
parsed.data.active,
Object.freeze(steps)
)
);
}
equals(other: InterventionTemplateValues) {
return (
this.name.equals(other.name) &&
this.description === other.description &&
this.active === other.active &&
this.steps.length === other.steps.length &&
this.steps.every((step, index) => {
const value = other.steps[index];
return value !== undefined && step.equals(value);
})
);
}
toPrimitive(): InterventionTemplateInput {
return {
name: this.name.toPrimitive(),
description: this.description,
active: this.active,
steps: this.steps.map((step) => step.toPrimitive()),
};
}
}

View File

@ -0,0 +1,114 @@
import { Name, UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { InterventionStep } from "../intervention-step.entity";
import {
InterventionError,
type InterventionInput,
InterventionInputSchema,
} from "../intervention-values";
import { TicketTarget } from "./ticket-values";
type Data = Omit<InterventionInput, "subject" | "ticketId" | "assigneeId" | "affected" | "steps">;
const keys: readonly (keyof Data)[] = [
"state",
"modality",
"plannedDate",
"startedAt",
"endedAt",
"minutes",
"diagnosis",
"workPerformed",
"resolution",
];
function optionalId(input: string | null): Result<Maybe<UniqueID>, InterventionError> {
if (input === null) {
return Result.ok(Maybe.none());
}
const result = UniqueID.create(input);
return result.isSuccess
? Result.ok(Maybe.some(result.data))
: Result.fail(new InterventionError("INVALID_REQUEST"));
}
export class InterventionValues {
private constructor(
public readonly subject: Name,
public readonly ticketId: Maybe<UniqueID>,
public readonly assigneeId: Maybe<UniqueID>,
private readonly data: Data,
public readonly affected: readonly TicketTarget[],
public readonly steps: readonly InterventionStep[]
) {
Object.freeze(data);
Object.freeze(affected);
Object.freeze(steps);
Object.freeze(this);
}
static create(input: InterventionInput): Result<InterventionValues, InterventionError> {
const parsed = InterventionInputSchema.safeParse(input);
if (!parsed.success) {
return Result.fail(new InterventionError("INVALID_REQUEST"));
}
const name = Name.create(parsed.data.subject, 200);
const ticket = optionalId(parsed.data.ticketId);
const assignee = optionalId(parsed.data.assigneeId);
if (!(name.isSuccess && ticket.isSuccess && assignee.isSuccess)) {
return Result.fail(new InterventionError("INVALID_REQUEST"));
}
const targets: TicketTarget[] = [];
for (const item of parsed.data.affected) {
const target = TicketTarget.create(item);
if (!target.isSuccess) {
return Result.fail(new InterventionError(target.error.code));
}
targets.push(target.data);
}
const steps: InterventionStep[] = [];
for (const item of parsed.data.steps) {
const step = InterventionStep.create(item);
if (!step.isSuccess) {
return Result.fail(step.error);
}
steps.push(step.data);
}
const {
subject: _,
ticketId: __,
assigneeId: ___,
affected: ____,
steps: _____,
...data
} = parsed.data;
return Result.ok(
new InterventionValues(name.data, ticket.data, assignee.data, data, targets, steps)
);
}
toPrimitive(): InterventionInput {
return {
...this.data,
subject: this.subject.toPrimitive(),
ticketId: this.ticketId.getOrUndefined()?.toPrimitive() ?? null,
assigneeId: this.assigneeId.getOrUndefined()?.toPrimitive() ?? null,
affected: this.affected.map((item) => item.toPrimitive()),
steps: this.steps.map((item) => item.toPrimitive()),
};
}
equals(other: InterventionValues) {
return (
this.subject.equals(other.subject) &&
this.ticketId.getOrUndefined()?.toPrimitive() ===
other.ticketId.getOrUndefined()?.toPrimitive() &&
this.assigneeId.getOrUndefined()?.toPrimitive() ===
other.assigneeId.getOrUndefined()?.toPrimitive() &&
keys.every((key) => this.data[key] === other.data[key]) &&
this.affected.length === other.affected.length &&
this.affected.every((target) => other.affected.some((item) => item.equals(target))) &&
this.steps.length === other.steps.length &&
this.steps.every((step, index) => {
const item = other.steps[index];
return item !== undefined && step.equals(item);
})
);
}
}

View File

@ -0,0 +1,75 @@
import type { Application, RequestHandler, Response } from "express";
import { Router } from "express";
import { z } from "zod/v4";
import { SaveInterventionTemplateSchema } from "../../../common/intervention-templates";
import type { InterventionTemplateUseCases } from "../../application/intervention-template.use-cases";
import { buildInterventionTemplate } from "../../application/intervention-template-response.builder";
const Empty = z.strictObject({});
const Scope = z.strictObject({ scope: z.union([z.literal("global"), z.uuid()]) });
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") });
}
export function registerInterventionTemplateRoutes(
app: Application,
useCases: InterventionTemplateUseCases,
authenticate: RequestHandler
) {
const router = Router();
router.get("/:scope", (request, response, next) => {
const params = Scope.safeParse(request.params);
if (!(params.success && Empty.safeParse(request.query).success)) {
fail(response, "INVALID_REQUEST");
return;
}
useCases
.list(params.data.scope)
.then((result) => {
if (!result.isSuccess) {
fail(response, result.error.code);
return;
}
response.json({
templates: result.data.templates.map((template) =>
buildInterventionTemplate(template, result.data.actorNames)
),
});
})
.catch(next);
});
for (const path of ["/:scope", "/:scope/:id/update"]) {
router.post(path, (request, response, next) => {
const params = (path.endsWith("update") ? Scope.extend({ id: z.uuid() }) : Scope).safeParse(
request.params
);
const body = SaveInterventionTemplateSchema.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.scope, id, body.data, actor.data.userId)
.then((result) => {
if (!result.isSuccess) {
fail(response, result.error.code);
return;
}
response.status(id ? 200 : 201).json({
template: buildInterventionTemplate(result.data.template, result.data.actorNames),
});
})
.catch(next);
});
}
app.use("/api/intervention-templates", authenticate, router);
}

View File

@ -0,0 +1,98 @@
import type { Application, RequestHandler, Response } from "express";
import { Router } from "express";
import { z } from "zod/v4";
import { SaveInterventionSchema } from "../../../common/interventions";
import type { InterventionUseCases } from "../../application/intervention.use-cases";
import {
buildIntervention,
buildInterventionLookups,
buildInterventions,
} from "../../application/intervention-response.builder";
const Empty = z.strictObject({});
const Company = z.strictObject({ companyId: z.uuid() });
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") });
}
export function registerInterventionRoutes(
app: Application,
useCases: InterventionUseCases,
authenticate: RequestHandler
) {
const router = Router();
router.get("/:companyId/lookups", (request, response, next) => {
const params = Company.safeParse(request.params);
if (!(params.success && Empty.safeParse(request.query).success)) {
fail(response, "INVALID_REQUEST");
return;
}
useCases
.lookups(params.data.companyId)
.then((result) => {
if (!result.isSuccess) {
fail(response, result.error.code);
return;
}
response.json(buildInterventionLookups(result.data));
})
.catch(next);
});
for (const path of ["/", "/:companyId"]) {
router.get(path, (request, response, next) => {
const params = (path === "/" ? Empty : Company).safeParse(request.params);
if (!(params.success && Empty.safeParse(request.query).success)) {
fail(response, "INVALID_REQUEST");
return;
}
const companyId =
"companyId" in params.data && typeof params.data.companyId === "string"
? params.data.companyId
: undefined;
useCases
.list(companyId)
.then((result) => {
if (!result.isSuccess) {
fail(response, result.error.code);
return;
}
response.json(buildInterventions(result.data));
})
.catch(next);
});
}
for (const path of ["/:companyId", "/:companyId/:id/update"]) {
router.post(path, (request, response, next) => {
const params = (
path.endsWith("update") ? Company.extend({ id: z.uuid() }) : Company
).safeParse(request.params);
const body = SaveInterventionSchema.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, id, body.data, actor.data.userId)
.then((result) => {
if (!result.isSuccess) {
fail(response, result.error.code);
return;
}
response.status(id ? 200 : 201).json({
intervention: buildIntervention(result.data.intervention, result.data.context),
});
})
.catch(next);
});
}
app.use("/api/interventions", authenticate, router);
}

View File

@ -0,0 +1,65 @@
import { DataTypes, QueryTypes, type Sequelize } from "sequelize";
export async function migrateInterventions(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, nullable = false) => ({
type: DataTypes.UUID,
allowNull: nullable,
references: { model, key: "id" },
onDelete: "RESTRICT",
});
const definitions = [
[
"intervention_counter",
{
id: { type: DataTypes.INTEGER, primaryKey: true },
nextNumber: { type: DataTypes.INTEGER, allowNull: false },
},
],
[
"interventions",
{
id: { type: DataTypes.UUID, primaryKey: true },
companyId: fk("companies"),
reference: { type: DataTypes.STRING(40), allowNull: false, unique: true },
requestKey: { type: DataTypes.UUID, allowNull: false, unique: true },
ticketId: fk("tickets", true),
assigneeId: fk("users", true),
values: { type: DataTypes.JSON, allowNull: false },
procedure: { type: DataTypes.JSON, allowNull: false },
},
],
[
"intervention_revisions",
{
id: { type: DataTypes.UUID, primaryKey: true },
interventionId: fk("interventions"),
actorId: fk("users"),
ticketId: fk("tickets", true),
assigneeId: fk("users", true),
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);
}
}
await database.query("INSERT IGNORE INTO intervention_counter (id,nextNumber) VALUES (1,1)");
const indexes = await database.query<{ name: string }>(
"SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='intervention_revisions'",
{ type: QueryTypes.SELECT }
);
if (!indexes.some((item) => item.name === "intervention_revision_position")) {
await query.addIndex("intervention_revisions", ["interventionId", "position"], {
name: "intervention_revision_position",
unique: true,
});
}
}

View File

@ -0,0 +1,140 @@
import { Name, UniqueID } from "@repo/rdx-ddd";
import { Maybe } from "@repo/rdx-utils";
import { z } from "zod/v4";
import { InterventionProcedureSchema } from "../../../common/interventions";
import { Intervention } from "../../domain/intervention.aggregate";
import { InterventionRevision } from "../../domain/intervention-revision.entity";
import { InterventionInputSchema } from "../../domain/intervention-values";
import { InterventionProcedure } from "../../domain/value-objects/intervention-procedure";
import { InterventionValues } from "../../domain/value-objects/intervention-values";
const Root = z.strictObject({
id: z.uuid(),
companyId: z.uuid(),
reference: z.string().regex(/^I-[0-9]{6,}$/),
requestKey: z.uuid(),
ticketId: z.uuid().nullable(),
assigneeId: z.uuid().nullable(),
values: InterventionInputSchema,
procedure: InterventionProcedureSchema.nullable(),
});
const Revision = z.strictObject({
id: z.uuid(),
interventionId: z.uuid(),
actorId: z.uuid(),
ticketId: z.uuid().nullable(),
assigneeId: z.uuid().nullable(),
values: InterventionInputSchema,
recordedAt: z.iso.datetime(),
position: z.number().int().nonnegative(),
});
export function interventionIdentity(value: string) {
const id = UniqueID.create(value);
if (!id.isSuccess) {
throw new Error("Identidad de intervention inconsistente.");
}
return id.data;
}
function json(value: unknown): unknown {
return typeof value === "string" ? JSON.parse(value) : value;
}
function values(value: unknown) {
const result = InterventionValues.create(InterventionInputSchema.parse(json(value)));
if (!result.isSuccess) {
throw new Error("Valores de intervention inconsistentes.");
}
return result.data;
}
export class InterventionPersistenceMapper {
toDomain(root: Record<string, unknown>, rows: readonly Record<string, unknown>[]) {
const current = Root.parse({
...root,
values: json(root.values),
procedure: json(root.procedure),
});
const history = rows
.map((row) => Revision.parse({ ...row, values: json(row.values) }))
.sort((a, b) => a.position - b.position);
const reference = Name.create(current.reference, 40);
if (
!(reference.isSuccess && history.length) ||
new Set(history.map((item) => item.id)).size !== history.length ||
history.some(
(item, index) =>
item.interventionId !== current.id ||
item.position !== index ||
item.ticketId !== item.values.ticketId ||
item.assigneeId !== item.values.assigneeId ||
(index > 0 && item.recordedAt < (history[index - 1]?.recordedAt ?? ""))
) ||
current.ticketId !== current.values.ticketId ||
current.assigneeId !== current.values.assigneeId
) {
throw new Error("Historial de intervention inconsistente.");
}
const revisions = history.map((item) =>
InterventionRevision.rehydrate(
{
values: values(item.values),
actorId: interventionIdentity(item.actorId),
recordedAt: item.recordedAt,
},
interventionIdentity(item.id)
)
);
const currentValues = values(current.values);
if (!revisions.at(-1)?.values.equals(currentValues)) {
throw new Error("Instantánea de intervention inconsistente.");
}
return Intervention.rehydrate(
{
companyId: interventionIdentity(current.companyId),
procedure: restoreProcedure(current.procedure),
reference: reference.data,
requestKey: interventionIdentity(current.requestKey),
values: currentValues,
history: revisions,
},
interventionIdentity(current.id)
);
}
toPersistence(intervention: Intervention) {
const input = intervention.values.toPrimitive();
return {
root: {
id: intervention.id.toPrimitive(),
companyId: intervention.companyId.toPrimitive(),
reference: intervention.reference.toPrimitive(),
requestKey: intervention.requestKey.toPrimitive(),
ticketId: input.ticketId,
assigneeId: input.assigneeId,
values: JSON.stringify(input),
procedure: JSON.stringify(intervention.procedure.getOrUndefined()?.toPrimitive() ?? null),
},
revisions: intervention.history.map((item, position) => ({
id: item.id.toPrimitive(),
interventionId: intervention.id.toPrimitive(),
actorId: item.actorId.toPrimitive(),
ticketId: item.values.toPrimitive().ticketId,
assigneeId: item.values.toPrimitive().assigneeId,
values: JSON.stringify(item.values.toPrimitive()),
recordedAt: item.recordedAt,
position,
})),
};
}
}
function restoreProcedure(
input: ReturnType<typeof InterventionProcedureSchema.parse> | null
): Maybe<InterventionProcedure> {
if (!input) {
return Maybe.none();
}
const result = InterventionProcedure.create(input);
if (!result.isSuccess) {
throw new Error("Procedimiento de intervención inconsistente.");
}
return Maybe.some(result.data);
}

View File

@ -0,0 +1,89 @@
import { DataTypes, QueryTypes, type Sequelize } from "sequelize";
import { interventionTemplateSeeds } from "../../domain/intervention-template-seed";
import { InterventionTemplatePersistenceMapper } from "./intervention-template-persistence.mapper";
export async function migrateInterventionTemplates(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: true,
references: { model, key: "id" },
onDelete: "RESTRICT",
});
const definitions = [
["intervention_template_lock", { id: { type: DataTypes.INTEGER, primaryKey: true } }],
[
"intervention_templates",
{
id: { type: DataTypes.UUID, primaryKey: true },
companyId: fk("companies"),
requestKey: { type: DataTypes.UUID, allowNull: false, unique: true },
values: { type: DataTypes.JSON, allowNull: false },
},
],
[
"intervention_template_revisions",
{
id: { type: DataTypes.UUID, primaryKey: true },
templateId: {
type: DataTypes.UUID,
allowNull: false,
references: { model: "intervention_templates", key: "id" },
onDelete: "RESTRICT",
},
actorId: fk("users"),
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);
}
}
await database.query("INSERT IGNORE INTO intervention_template_lock (id) VALUES (1)");
const indexes = await database.query<{ name: string }>(
"SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='intervention_template_revisions'",
{ type: QueryTypes.SELECT }
);
if (!indexes.some((item) => item.name === "intervention_template_revision_position")) {
await query.addIndex("intervention_template_revisions", ["templateId", "position"], {
name: "intervention_template_revision_position",
unique: true,
});
}
await database.transaction(async (transaction) => {
await database.query("SELECT id FROM intervention_template_lock WHERE id=1 FOR UPDATE", {
type: QueryTypes.SELECT,
transaction,
});
const existing = await database.query<{ requestKey: string }>(
"SELECT requestKey FROM intervention_templates",
{ type: QueryTypes.SELECT, transaction }
);
const mapper = new InterventionTemplatePersistenceMapper();
for (const seed of interventionTemplateSeeds()) {
if (existing.some((item) => item.requestKey === seed.requestKey.toPrimitive())) {
continue;
}
const persisted = mapper.toPersistence(seed);
await database.query(
"INSERT INTO intervention_templates (id,companyId,requestKey,`values`) VALUES (:id,:companyId,:requestKey,:values)",
{ replacements: persisted.root, transaction }
);
for (const revision of persisted.revisions) {
await database.query(
"INSERT INTO intervention_template_revisions (id,templateId,`values`,actorId,recordedAt,position) VALUES (:id,:templateId,:values,:actorId,:recordedAt,:position)",
{ replacements: revision, transaction }
);
}
}
});
}

View File

@ -0,0 +1,102 @@
import { UniqueID } from "@repo/rdx-ddd";
import { Maybe } from "@repo/rdx-utils";
import { z } from "zod/v4";
import { InterventionTemplate } from "../../domain/intervention-template.aggregate";
import { InterventionTemplateRevision } from "../../domain/intervention-template-revision.entity";
import { InterventionTemplateInputSchema } from "../../domain/intervention-template-values";
import { InterventionTemplateValues } from "../../domain/value-objects/intervention-template-values";
const JsonValues = z.preprocess(
(value) => (typeof value === "string" ? JSON.parse(value) : value),
InterventionTemplateInputSchema
);
const Root = z.object({
id: z.uuid(),
companyId: z.uuid().nullable(),
requestKey: z.uuid(),
values: JsonValues,
});
const Revision = z.object({
id: z.uuid(),
templateId: z.uuid(),
values: JsonValues,
actorId: z.uuid().nullable(),
recordedAt: z.iso.datetime(),
position: z.number().int().nonnegative(),
});
function identifier(value: string) {
const result = UniqueID.create(value);
if (!result.isSuccess) {
throw new Error("Invalid intervention template identifier");
}
return result.data;
}
function values(input: z.infer<typeof InterventionTemplateInputSchema>) {
const result = InterventionTemplateValues.create(input);
if (!result.isSuccess) {
throw new Error("Invalid intervention template values");
}
return result.data;
}
export class InterventionTemplatePersistenceMapper {
toDomain(row: unknown, revisionRows: unknown[]): InterventionTemplate {
const root = Root.parse(row);
const revisions = revisionRows
.map((item) => Revision.parse(item))
.sort((a, b) => a.position - b.position);
if (
!revisions.length ||
new Set(revisions.map((item) => item.id)).size !== revisions.length ||
revisions.some(
(item, index) =>
item.templateId !== root.id ||
item.position !== index ||
item.recordedAt < (revisions[index - 1]?.recordedAt ?? "")
)
) {
throw new Error("Invalid intervention template history");
}
const current = values(root.values);
const history = revisions.map((item) =>
InterventionTemplateRevision.rehydrate(
{
values: values(item.values),
actorId: item.actorId ? Maybe.some(identifier(item.actorId)) : Maybe.none(),
recordedAt: item.recordedAt,
},
identifier(item.id)
)
);
if (!history.at(-1)?.values.equals(current)) {
throw new Error("Inconsistent intervention template snapshot");
}
return InterventionTemplate.rehydrate(
{
companyId: root.companyId ? Maybe.some(identifier(root.companyId)) : Maybe.none(),
requestKey: identifier(root.requestKey),
values: current,
history,
},
identifier(root.id)
);
}
toPersistence(template: InterventionTemplate) {
return {
root: {
id: template.id.toPrimitive(),
companyId: template.companyId.getOrUndefined()?.toPrimitive() ?? null,
requestKey: template.requestKey.toPrimitive(),
values: JSON.stringify(template.values.toPrimitive()),
},
revisions: template.history.map((revision, position) => ({
id: revision.id.toPrimitive(),
templateId: template.id.toPrimitive(),
values: JSON.stringify(revision.values.toPrimitive()),
actorId: revision.actorId.getOrUndefined()?.toPrimitive() ?? null,
recordedAt: revision.recordedAt,
position,
})),
};
}
}

View File

@ -0,0 +1,122 @@
import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { QueryTypes, type Sequelize, type Transaction } from "sequelize";
import type { IInterventionTemplateRepository } from "../../application/intervention-template.repository";
import type { InterventionTemplate } from "../../domain/intervention-template.aggregate";
import { InterventionTemplateError } from "../../domain/intervention-template-values";
import { InterventionTemplatePersistenceMapper } from "./intervention-template-persistence.mapper";
export class SequelizeInterventionTemplateRepository implements IInterventionTemplateRepository {
private readonly mapper = new InterventionTemplatePersistenceMapper();
constructor(private readonly database: Sequelize) {}
private async exists(companyId: UniqueID | undefined, transaction?: Transaction) {
if (!companyId) {
return true;
}
const rows = await this.database.query("SELECT id FROM companies WHERE id=:id", {
replacements: { id: companyId.toPrimitive() },
type: QueryTypes.SELECT,
...(transaction ? { transaction } : {}),
});
return rows.length > 0;
}
private async read(transaction?: Transaction) {
const rows = await this.database.query("SELECT * FROM intervention_templates", {
type: QueryTypes.SELECT,
...(transaction ? { transaction } : {}),
});
const revisions = await this.database.query<{ templateId: string }>(
"SELECT * FROM intervention_template_revisions ORDER BY position",
{ type: QueryTypes.SELECT, ...(transaction ? { transaction } : {}) }
);
const users = await this.database.query<{ id: string; name: string }>(
"SELECT id,name FROM users",
{ type: QueryTypes.SELECT, ...(transaction ? { transaction } : {}) }
);
return {
templates: rows.map((row) => {
const parsed = this.rowIdentifier(row);
return this.mapper.toDomain(
row,
revisions.filter((item) => item.templateId === parsed)
);
}),
actorNames: new Map(users.map((user) => [user.id, user.name])),
};
}
private rowIdentifier(row: object) {
if (!("id" in row) || typeof row.id !== "string") {
throw new Error("Invalid intervention template row");
}
return row.id;
}
async list(companyId: UniqueID | undefined) {
return this.database.transaction(async (transaction) => {
if (!(await this.exists(companyId, transaction))) {
return Result.fail(new InterventionTemplateError("COMPANY_NOT_FOUND"));
}
const context = await this.read(transaction);
return Result.ok({
...context,
templates: context.templates.filter(
(item) =>
item.companyId.getOrUndefined() === undefined ||
(companyId !== undefined && item.companyId.getOrUndefined()?.equals(companyId) === true)
),
});
});
}
async save(
companyId: UniqueID | undefined,
action: (
templates: InterventionTemplate[]
) => Result<InterventionTemplate, InterventionTemplateError>
) {
return this.database.transaction(async (transaction) => {
await this.database.query("SELECT id FROM intervention_template_lock WHERE id=1 FOR UPDATE", {
type: QueryTypes.SELECT,
transaction,
});
if (!(await this.exists(companyId, transaction))) {
return Result.fail(new InterventionTemplateError("COMPANY_NOT_FOUND"));
}
const context = await this.read(transaction);
const result = action(context.templates);
if (!result.isSuccess) {
return Result.fail(result.error);
}
const template = result.data;
const previous = context.templates.find((item) => item.id.equals(template.id));
await this.write(template, previous, transaction);
return Result.ok({ ...context, template });
});
}
private async write(
template: InterventionTemplate,
previous: InterventionTemplate | undefined,
transaction: Transaction
) {
if (previous === template) {
return;
}
const persisted = this.mapper.toPersistence(template);
if (previous) {
await this.database.query("UPDATE intervention_templates SET `values`=:values WHERE id=:id", {
replacements: persisted.root,
transaction,
});
} else {
await this.database.query(
"INSERT INTO intervention_templates (id,companyId,requestKey,`values`) VALUES (:id,:companyId,:requestKey,:values)",
{ replacements: persisted.root, transaction }
);
}
for (const revision of persisted.revisions.slice(previous?.history.length ?? 0)) {
await this.database.query(
"INSERT INTO intervention_template_revisions (id,templateId,`values`,actorId,recordedAt,position) VALUES (:id,:templateId,:values,:actorId,:recordedAt,:position)",
{ replacements: revision, transaction }
);
}
}
}

View File

@ -0,0 +1,160 @@
import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { QueryTypes, type Sequelize, type Transaction } from "sequelize";
import { z } from "zod/v4";
import type {
IInterventionRepository,
InterventionContext,
InterventionLookups,
InterventionSaveContext,
} from "../../application/intervention.repository";
import type { IInterventionTemplateRepository } from "../../application/intervention-template.repository";
import type { ITicketRepository } from "../../application/ticket.repository";
import type { Intervention } from "../../domain/intervention.aggregate";
import { InterventionError } from "../../domain/intervention-values";
import { InterventionPersistenceMapper } from "./intervention-persistence.mapper";
export class SequelizeInterventionRepository implements IInterventionRepository {
private readonly mapper = new InterventionPersistenceMapper();
constructor(
private readonly database: Sequelize,
private readonly tickets: ITicketRepository,
private readonly templates: IInterventionTemplateRepository
) {}
private async read(
company: UniqueID | undefined,
transaction: Transaction
): Promise<Result<InterventionContext, InterventionError>> {
const context = await this.tickets.list(company);
if (!context.isSuccess) {
return Result.fail(new InterventionError(context.error.code));
}
const roots = await this.database.query<Record<string, unknown>>(
"SELECT id,companyId,reference,requestKey,ticketId,assigneeId,`values`,`procedure` FROM interventions" +
(company ? " WHERE companyId=:company" : ""),
{
transaction,
type: QueryTypes.SELECT,
replacements: { company: company?.toPrimitive() ?? null },
}
);
const revisions = await this.database.query<Record<string, unknown>>(
"SELECT r.id,r.interventionId,r.actorId,r.ticketId,r.assigneeId,r.`values`,r.recordedAt,r.position FROM intervention_revisions r JOIN interventions i ON i.id=r.interventionId" +
(company ? " WHERE i.companyId=:company" : ""),
{
transaction,
type: QueryTypes.SELECT,
replacements: { company: company?.toPrimitive() ?? null },
}
);
return Result.ok({
...context.data,
interventions: roots.map((row) =>
this.mapper.toDomain(
row,
revisions.filter((item) => item.interventionId === row.id)
)
),
});
}
list(company?: UniqueID) {
return this.database.transaction((transaction) => this.read(company, transaction));
}
async lookups(company: UniqueID): Promise<Result<InterventionLookups, InterventionError>> {
const [context, lookups, templates] = await Promise.all([
this.tickets.list(company),
this.tickets.lookups(company),
this.templates.list(company),
]);
if (!context.isSuccess) {
return Result.fail(new InterventionError(context.error.code));
}
if (!lookups.isSuccess) {
return Result.fail(new InterventionError(lookups.error.code));
}
if (!templates.isSuccess) {
return Result.fail(new InterventionError(templates.error.code));
}
return Result.ok({
context: context.data,
lookups: lookups.data,
templates: templates.data.templates,
templateActors: templates.data.actorNames,
});
}
save(
company: UniqueID,
requestKey: UniqueID,
action: (context: InterventionSaveContext) => Result<Intervention, InterventionError>
) {
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,
});
await this.database.query("SELECT id FROM intervention_counter WHERE id=1 FOR UPDATE", {
transaction,
type: QueryTypes.SELECT,
});
const all = await this.read(undefined, transaction);
const lookups = await this.lookups(company);
if (!all.isSuccess) {
return Result.fail(all.error);
}
if (!lookups.isSuccess) {
return Result.fail(lookups.error);
}
const sequence = await this.database.query<{ nextNumber: number }>(
"SELECT nextNumber FROM intervention_counter WHERE id=1",
{ transaction, type: QueryTypes.SELECT }
);
const scoped = {
...all.data,
...lookups.data,
tickets: lookups.data.context.tickets,
interventions: all.data.interventions.filter((item) => item.companyId.equals(company)),
nextNumber: z.number().int().positive().parse(sequence[0]?.nextNumber),
requestIntervention: all.data.interventions.find((item) =>
item.requestKey.equals(requestKey)
),
};
const result = action(scoped);
if (!result.isSuccess) {
return Result.fail(result.error);
}
const previous = all.data.interventions.find((item) => item.id.equals(result.data.id));
await this.write(result.data, previous, transaction);
return Result.ok({ intervention: result.data, context: all.data });
});
}
private async write(
intervention: Intervention,
previous: Intervention | undefined,
transaction: Transaction
) {
if (previous === intervention) {
return;
}
const persisted = this.mapper.toPersistence(intervention);
await this.database.query(
previous
? "UPDATE interventions SET ticketId=:ticketId,assigneeId=:assigneeId,`values`=:values WHERE id=:id"
: "INSERT INTO interventions (id,companyId,reference,requestKey,ticketId,assigneeId,`values`,`procedure`) VALUES (:id,:companyId,:reference,:requestKey,:ticketId,:assigneeId,:values,:procedure)",
{ replacements: persisted.root, transaction }
);
for (const row of persisted.revisions.slice(previous?.history.length ?? 0)) {
await this.database.query(
"INSERT INTO intervention_revisions (id,interventionId,actorId,ticketId,assigneeId,`values`,recordedAt,position) VALUES (:id,:interventionId,:actorId,:ticketId,:assigneeId,:values,:recordedAt,:position)",
{ replacements: row, transaction }
);
}
if (!previous) {
await this.database.query(
"UPDATE intervention_counter SET nextNumber=nextNumber+1 WHERE id=1",
{ transaction }
);
}
}
}

View File

@ -8,6 +8,8 @@ import { CertificateConfigurationUseCases } from "./application/certificate-conf
import { CompanyUseCases } from "./application/company.use-cases";
import { CompanyInputMapper } from "./application/company-input.mapper";
import { FolderMappingUseCases } from "./application/folder-mapping.use-cases";
import { InterventionUseCases } from "./application/intervention.use-cases";
import { InterventionTemplateUseCases } from "./application/intervention-template.use-cases";
import type { IMailInventoryService } from "./application/mail-configuration.repository";
import { MailConfigurationUseCases } from "./application/mail-configuration.use-cases";
import { MailboxUseCases } from "./application/mailbox.use-cases";
@ -25,6 +27,8 @@ import { registerApplicationConfigurationRoutes } from "./infrastructure/http/ap
import { registerCertificateConfigurationRoutes } from "./infrastructure/http/certificate-configuration.routes";
import { registerCompanyRoutes } from "./infrastructure/http/company.routes";
import { registerFolderMappingRoutes } from "./infrastructure/http/folder-mapping.routes";
import { registerInterventionRoutes } from "./infrastructure/http/intervention.routes";
import { registerInterventionTemplateRoutes } from "./infrastructure/http/intervention-template.routes";
import { registerMailRoutes } from "./infrastructure/http/mail-configuration.routes";
import { registerMailboxRoutes } from "./infrastructure/http/mailbox.routes";
import { registerPrinterConfigurationRoutes } from "./infrastructure/http/printer-configuration.routes";
@ -43,6 +47,8 @@ import { migrateCertificateConfigurations } from "./infrastructure/persistence/c
import { initializeCompanyModels } from "./infrastructure/persistence/company.model";
import { CompanyPersistenceMapper } from "./infrastructure/persistence/company-persistence.mapper";
import { migrateFolderMappings } from "./infrastructure/persistence/folder-mapping-migration";
import { migrateInterventions } from "./infrastructure/persistence/intervention-migration";
import { migrateInterventionTemplates } from "./infrastructure/persistence/intervention-template-migration";
import { migrateMailConfigurations } from "./infrastructure/persistence/mail-configuration-migration";
import { migrateMailboxes } from "./infrastructure/persistence/mailbox-migration";
import { migrateCompanies } from "./infrastructure/persistence/migrate-companies";
@ -55,6 +61,8 @@ import { SequelizeApplicationConfigurationRepository } from "./infrastructure/pe
import { SequelizeCertificateConfigurationRepository } from "./infrastructure/persistence/sequelize-certificate-configuration.repository";
import { SequelizeCompanyRepository } from "./infrastructure/persistence/sequelize-company.repository";
import { SequelizeFolderMappingRepository } from "./infrastructure/persistence/sequelize-folder-mapping.repository";
import { SequelizeInterventionRepository } from "./infrastructure/persistence/sequelize-intervention.repository";
import { SequelizeInterventionTemplateRepository } from "./infrastructure/persistence/sequelize-intervention-template.repository";
import { SequelizeMailConfigurationRepository } from "./infrastructure/persistence/sequelize-mail-configuration.repository";
import { SequelizeMailboxRepository } from "./infrastructure/persistence/sequelize-mailbox.repository";
import { SequelizePrinterConfigurationRepository } from "./infrastructure/persistence/sequelize-printer-configuration.repository";
@ -74,6 +82,9 @@ export async function createCompanyModule(
) {
await migrateCompanies(database);
await migratePeople(database);
await migrateInterventionTemplates(database);
const interventionTemplateRepository = new SequelizeInterventionTemplateRepository(database);
const interventionTemplates = new InterventionTemplateUseCases(interventionTemplateRepository);
initializeCompanyModels(database);
initializePeopleModels(database);
const repository = new SequelizeCompanyRepository(database, new CompanyPersistenceMapper());
@ -94,8 +105,15 @@ export async function createCompanyModule(
resources: IResourceInventoryService
) => {
await migrateTickets(database);
const tickets = new TicketUseCases(
new SequelizeTicketRepository(database, inventory, resources)
const ticketRepository = new SequelizeTicketRepository(database, inventory, resources);
const tickets = new TicketUseCases(ticketRepository);
await migrateInterventions(database);
const interventions = new InterventionUseCases(
new SequelizeInterventionRepository(
database,
ticketRepository,
interventionTemplateRepository
)
);
await migrateProcesses(database);
const processes = new ProcessUseCases(
@ -127,6 +145,7 @@ export async function createCompanyModule(
);
return {
register: (app: Parameters<typeof registerWorkProfileRoutes>[0]) => {
registerInterventionRoutes(app, interventions, authenticate);
registerTicketRoutes(app, tickets, authenticate);
registerProcessRoutes(app, processes, authenticate);
registerRemoteConnectionRoutes(app, remote, authenticate);
@ -155,6 +174,7 @@ export async function createCompanyModule(
};
},
register: (app: Parameters<typeof registerCompanyRoutes>[0]) => {
registerInterventionTemplateRoutes(app, interventionTemplates, authenticate);
registerCompanyRoutes(app, useCases, authenticate);
registerTeleworkAuthorizationRoutes(app, telework, authenticate);
registerAccessRoutes(app, access, authenticate, legacyResourceWrites);

View File

@ -8,6 +8,8 @@ export * from "./dto/shared/access.dto";
export * from "./dto/shared/company.dto";
export * from "./dto/shared/people.dto";
export * from "./folder-mappings";
export * from "./intervention-templates";
export * from "./interventions";
export * from "./mail-configurations";
export * from "./mailboxes";
export * from "./printer-configurations";

View File

@ -0,0 +1,41 @@
import { z } from "zod/v4";
import { InterventionTemplateInputSchema } from "../api/domain/intervention-template-values";
export type {
InterventionStepInput,
InterventionTemplateInput,
} from "../api/domain/intervention-template-values";
export {
InterventionStepInputSchema,
InterventionTemplateInputSchema,
} from "../api/domain/intervention-template-values";
export const SaveInterventionTemplateSchema = z.strictObject({
values: InterventionTemplateInputSchema,
requestKey: z.uuid(),
expectedRevisionId: z.uuid().nullable(),
});
export const InterventionTemplateSchema = z.strictObject({
id: z.uuid(),
companyId: z.uuid().nullable(),
values: InterventionTemplateInputSchema,
history: z
.array(
z.strictObject({
id: z.uuid(),
values: InterventionTemplateInputSchema,
actorId: z.uuid().nullable(),
actorName: z.string().min(1),
recordedAt: z.iso.datetime(),
})
)
.min(1),
});
export const InterventionTemplatesSchema = z.strictObject({
templates: z.array(InterventionTemplateSchema),
});
export const InterventionTemplateResponseSchema = z.strictObject({
template: InterventionTemplateSchema,
});
export type InterventionTemplateDTO = z.infer<typeof InterventionTemplateSchema>;
export type SaveInterventionTemplateInput = z.infer<typeof SaveInterventionTemplateSchema>;

View File

@ -0,0 +1,67 @@
import { z } from "zod/v4";
import { InterventionTemplateInputSchema } from "../api/domain/intervention-template-values";
import {
InterventionInputSchema,
InterventionModalitySchema,
InterventionStateSchema,
InterventionStepStateSchema,
} from "../api/domain/intervention-values";
import { InterventionTemplateSchema } from "./intervention-templates";
import { TicketListSchema, TicketLookupsSchema, TicketSchema } from "./tickets";
export type { InterventionCheckInput, InterventionInput } from "../api/domain/intervention-values";
export {
InterventionInputSchema,
InterventionModalitySchema,
InterventionStateSchema,
InterventionStepStateSchema,
};
export const InterventionProcedureSchema = z.strictObject({
templateId: z.uuid(),
revisionId: z.uuid(),
values: InterventionTemplateInputSchema,
});
export const SaveInterventionSchema = z.strictObject({
values: InterventionInputSchema,
requestKey: z.uuid(),
expectedRevisionId: z.uuid().nullable(),
template: z.strictObject({ id: z.uuid(), revisionId: z.uuid() }).nullable(),
});
export const InterventionSchema = z.strictObject({
id: z.uuid(),
companyId: z.uuid(),
companyName: z.string(),
reference: z.string(),
values: InterventionInputSchema,
assigneeName: z.string().nullable(),
ticketReference: z.string().nullable(),
procedure: InterventionProcedureSchema.nullable(),
history: z
.array(
z.strictObject({
id: z.uuid(),
values: InterventionInputSchema,
actorId: z.uuid(),
actorName: z.string(),
recordedAt: z.iso.datetime(),
})
)
.min(1),
});
export const InterventionListSchema = z.strictObject({
interventions: z.array(InterventionSchema),
companies: TicketListSchema.shape.companies,
technicians: TicketListSchema.shape.technicians,
});
export const InterventionLookupsSchema = TicketLookupsSchema.extend({
tickets: z.array(TicketSchema),
templates: z.array(InterventionTemplateSchema),
});
export const InterventionResponseSchema = z.strictObject({ intervention: InterventionSchema });
export type InterventionDTO = z.infer<typeof InterventionSchema>;
export type InterventionListDTO = z.infer<typeof InterventionListSchema>;
export type InterventionLookupsDTO = z.infer<typeof InterventionLookupsSchema>;
export type SaveInterventionInput = z.infer<typeof SaveInterventionSchema>;

View File

@ -9,6 +9,8 @@ import { useTranslation } from "react-i18next";
import { AccessWorkspacePage } from "../access/access-workspace";
import { companyApi, companyKeys } from "../api/company-api";
import { InterventionTemplatesPage } from "../intervention-templates/templates-page";
import { InterventionsPage } from "../interventions/interventions-page";
import { TicketsPage } from "../tickets/tickets-page";
import { CompaniesPage } from "./companies-page";
@ -18,6 +20,7 @@ import { CompanyFeedback } from "./company-feedback";
type WorkspaceLocation =
| { area: "company"; id: string }
| { area: "tickets" }
| { area: "interventions" }
| { area: "directory" }
| { area: "catalogs" };
@ -35,7 +38,9 @@ export function CompaniesWorkspace({
const { t: accessTranslation } = useTranslation("access");
const query = useQuery({ queryKey: companyKeys.list, queryFn: companyApi.list });
const [location, setLocation] = useState<WorkspaceLocation>();
const [catalog, setCatalog] = useState<"access" | "assets" | "resources">("access");
const [catalog, setCatalog] = useState<
"access" | "assets" | "resources" | "intervention-templates"
>("access");
const companies = query.data?.companies ?? [];
const current =
location ?? (companies[0] ? { area: "company", id: companies[0].id } : { area: "directory" });
@ -74,6 +79,16 @@ export function CompaniesWorkspace({
>
T
</Button>
<Button
aria-label={t("interventions.global")}
aria-pressed={current.area === "interventions"}
className="desk-space-button"
onClick={() => setLocation({ area: "interventions" })}
title={t("interventions.global")}
variant="ghost"
>
I
</Button>
<div className="desk-rail-separator" />
{companies.map((company) => (
<Button
@ -147,6 +162,13 @@ export function CompaniesWorkspace({
>
{assetTranslation("title")}
</Button>
<Button
aria-pressed={catalog === "intervention-templates"}
onClick={() => setCatalog("intervention-templates")}
variant="ghost"
>
{t("interventionTemplates.title")}
</Button>
<Button
aria-pressed={catalog === "resources"}
onClick={() => setCatalog("resources")}
@ -157,7 +179,7 @@ export function CompaniesWorkspace({
</>
) : (
<Button aria-pressed variant="ghost">
{t(current.area === "tickets" ? "tickets.global" : "title")}
{t(workspaceTitle(current.area))}
</Button>
)}
</nav>
@ -181,10 +203,12 @@ function GlobalCatalogContent({
catalog,
csrfToken,
}: {
catalog: "access" | "assets" | "resources";
catalog: "access" | "assets" | "resources" | "intervention-templates";
csrfToken: string;
}) {
switch (catalog) {
case "intervention-templates":
return <InterventionTemplatesPage csrfToken={csrfToken} />;
case "access":
return <AccessWorkspacePage csrfToken={csrfToken} scope="global" />;
case "assets":
@ -198,6 +222,8 @@ function workspaceTitle(area: WorkspaceLocation["area"]) {
switch (area) {
case "catalogs":
return "globalCatalogs";
case "interventions":
return "interventions.global";
case "tickets":
return "tickets.global";
default:
@ -211,11 +237,13 @@ function WorkspaceContent({
onSelectCompany,
}: {
area: WorkspaceLocation["area"];
catalog: "access" | "assets" | "resources";
catalog: "access" | "assets" | "resources" | "intervention-templates";
csrfToken: string;
onSelectCompany: (id: string) => void;
}) {
switch (area) {
case "interventions":
return <InterventionsPage csrfToken={csrfToken} />;
case "tickets":
return <TicketsPage csrfToken={csrfToken} />;
case "directory":

View File

@ -24,6 +24,8 @@ import { companyApi, companyKeys } from "../api/company-api";
import { ApplicationConfigurationsPage } from "../applications/application-configurations-page";
import { CertificateConfigurationsPage } from "../certificates/certificate-configurations-page";
import { FolderMappingsPage } from "../folders/folder-mappings-page";
import { InterventionTemplatesPage } from "../intervention-templates/templates-page";
import { InterventionsPage } from "../interventions/interventions-page";
import { MailConfigurationsPage } from "../mail/mail-configurations-page";
import { MailboxRelations } from "../mail/mailbox-relations";
import { PrinterConfigurationsPage } from "../printers/printer-configurations-page";
@ -114,6 +116,8 @@ function SiteSection({ company, csrfToken }: { company: CompanyDTO; csrfToken: s
);
}
type CompanyTab =
| "interventions"
| "intervention-templates"
| "tickets"
| "processes"
| "remote"
@ -227,6 +231,8 @@ export function CompanyDetail({
["telework", t("telework.title"), KeyRoundIcon],
["remote", t("remote.title"), KeyRoundIcon],
["processes", t("processes.title"), LayoutTemplateIcon],
["intervention-templates", t("interventionTemplates.title"), LayoutTemplateIcon],
["interventions", t("interventions.title"), LayoutTemplateIcon],
["tickets", t("tickets.title"), LayoutTemplateIcon],
["people", `${t("people")} (${company.people.length})`, UsersIcon],
[
@ -277,6 +283,10 @@ function CompanySection({
tab: CompanyTab;
}) {
switch (tab) {
case "intervention-templates":
return <InterventionTemplatesPage csrfToken={csrfToken} scope={company.id} />;
case "interventions":
return <InterventionsPage companyId={company.id} csrfToken={csrfToken} />;
case "tickets":
return <TicketsPage companyId={company.id} csrfToken={csrfToken} />;
case "processes":

View File

@ -1,6 +1,11 @@
import { applicationsEnglish, applicationsSpanish } from "./applications/application-i18n";
import { certificatesEnglish, certificatesSpanish } from "./certificates/certificate-i18n";
import { foldersEnglish, foldersSpanish } from "./folders/folder-i18n";
import {
interventionTemplatesEnglish,
interventionTemplatesSpanish,
} from "./intervention-templates/template-i18n";
import { interventionsEnglish, interventionsSpanish } from "./interventions/intervention-i18n";
import { mailEnglish, mailSpanish } from "./mail/mail-i18n";
import { mailboxEnglish, mailboxSpanish } from "./mail/mailbox-i18n";
import { printersEnglish, printersSpanish } from "./printers/printer-i18n";
@ -12,6 +17,8 @@ import { teleworkEnglish, teleworkSpanish } from "./telework/telework-i18n";
import { ticketsEnglish, ticketsSpanish } from "./tickets/ticket-i18n";
export const companiesSpanish = {
interventionTemplates: interventionTemplatesSpanish,
interventions: interventionsSpanish,
tickets: ticketsSpanish,
processes: processesSpanish,
remote: remoteSpanish,
@ -138,6 +145,8 @@ export const companiesSpanish = {
},
};
export const companiesEnglish = {
interventionTemplates: interventionTemplatesEnglish,
interventions: interventionsEnglish,
tickets: ticketsEnglish,
processes: processesEnglish,
remote: remoteEnglish,

View File

@ -0,0 +1,50 @@
import { z } from "zod/v4";
import {
InterventionTemplateResponseSchema,
InterventionTemplatesSchema,
type SaveInterventionTemplateInput,
} from "../../common/intervention-templates";
export const interventionTemplateKeys = {
list: (scope: string) => ["intervention-templates", scope],
};
export class InterventionTemplateApiError extends Error {
constructor(public readonly code: string) {
super(code);
}
}
async function request(
scope: string,
id?: string,
body?: SaveInterventionTemplateInput,
csrfToken?: string
) {
const response = await fetch(`/api/intervention-templates/${scope}${id ? `/${id}/update` : ""}`, {
method: body === undefined ? "GET" : "POST",
credentials: "same-origin",
headers:
body === undefined
? {}
: { "content-type": "application/json", "x-csrf-token": csrfToken ?? "" },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const data: unknown = await response.json();
if (!response.ok) {
const parsed = z.object({ code: z.string() }).safeParse(data);
throw new InterventionTemplateApiError(parsed.success ? parsed.data.code : "INTERNAL_ERROR");
}
return data;
}
export const interventionTemplateApi = {
async list(scope: string) {
return InterventionTemplatesSchema.parse(await request(scope));
},
async save(
scope: string,
id: string | undefined,
input: SaveInterventionTemplateInput,
csrfToken: string
) {
return InterventionTemplateResponseSchema.parse(await request(scope, id, input, csrfToken));
},
};

View File

@ -0,0 +1,50 @@
import { useTranslation } from "react-i18next";
import type {
InterventionTemplateDTO,
InterventionTemplateInput,
} from "../../common/intervention-templates";
export function TemplateSteps({ values }: { values: InterventionTemplateInput }) {
const { t } = useTranslation("companies");
return (
<>
<p className="whitespace-pre-wrap text-sm">{values.description}</p>
<ol className="list-decimal pl-5 space-y-2 text-sm">
{values.steps.map((step) => (
<li key={step.id}>
<span className="font-medium">{step.title}</span>
{step.required && (
<span className="ml-2 text-muted-foreground">
· {t("interventionTemplates.required")}
</span>
)}
{step.instructions && (
<p className="whitespace-pre-wrap text-muted-foreground">{step.instructions}</p>
)}
</li>
))}
</ol>
</>
);
}
export function TemplateHistory({ template }: { template: InterventionTemplateDTO }) {
const { t, i18n } = useTranslation("companies");
return (
<details>
<summary className="cursor-pointer text-sm">{t("history")}</summary>
<ol className="space-y-3 mt-3">
{[...template.history].reverse().map((revision) => (
<li className="rounded border p-3 space-y-2" key={revision.id}>
<p className="text-sm">
{new Date(revision.recordedAt).toLocaleString(i18n.language)} · {revision.actorName}
</p>
<p className="font-medium">
{revision.values.name} · {t(revision.values.active ? "active" : "inactive")}
</p>
<TemplateSteps values={revision.values} />
</li>
))}
</ol>
</details>
);
}

View File

@ -0,0 +1,155 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { UniqueID } from "@repo/rdx-ddd";
import { Button } from "@repo/shadcn-ui/components/button";
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
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 { Textarea } from "@repo/shadcn-ui/components/textarea";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useEffect, useId, useRef, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import {
type InterventionTemplateDTO,
type InterventionTemplateInput,
InterventionTemplateInputSchema,
} from "../../common/intervention-templates";
import { InterventionTemplateApiError, interventionTemplateApi } from "./template-api";
import { InterventionTemplateFeedback, InterventionTemplateHelp } from "./template-help";
import { TemplateStepFields } from "./template-step-fields";
export function newTemplateStep() {
return {
id: UniqueID.generateNewID().toPrimitive(),
title: "",
instructions: "",
required: false,
};
}
export function copyTemplateValues(
template: InterventionTemplateDTO,
name: string
): InterventionTemplateInput {
return {
...template.values,
name,
active: true,
steps: template.values.steps.map((step) => ({
...step,
id: UniqueID.generateNewID().toPrimitive(),
})),
};
}
export function InterventionTemplateForm({
scope,
template,
initialValues,
csrfToken,
onSaved,
}: {
scope: string;
template?: InterventionTemplateDTO | undefined;
initialValues?: InterventionTemplateInput | undefined;
csrfToken: string;
onSaved: (id: string) => void;
}) {
const { t } = useTranslation("companies");
const prefix = useId();
const client = useQueryClient();
const [requestKey] = useState(() => UniqueID.generateNewID().toPrimitive());
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const form = useForm<InterventionTemplateInput>({
resolver: zodResolver(InterventionTemplateInputSchema),
defaultValues: template?.values ??
initialValues ?? { name: "", description: "", active: true, steps: [newTemplateStep()] },
});
const steps = useFieldArray({ control: form.control, name: "steps", keyName: "fieldKey" });
const mutation = useMutation({
mutationFn: (values: InterventionTemplateInput) =>
interventionTemplateApi.save(
scope,
template?.id,
{ values, requestKey, expectedRevisionId: template?.history.at(-1)?.id ?? null },
csrfToken
),
onError: async (error) => {
if (error instanceof InterventionTemplateApiError && error.code === "TEMPLATE_CHANGED") {
await client.invalidateQueries({ queryKey: ["intervention-templates"] });
}
},
onSuccess: async (response) => {
await client.invalidateQueries({ queryKey: ["intervention-templates"] });
if (mounted.current) {
onSaved(response.template.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={`${prefix}-name`}>{t("name")}</Label>
<Input id={`${prefix}-name`} maxLength={160} required {...form.register("name")} />
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-description`}>{t("interventionTemplates.description")}</Label>
<Textarea
id={`${prefix}-description`}
maxLength={4000}
rows={3}
{...form.register("description")}
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
checked={form.watch("active")}
id={`${prefix}-active`}
onCheckedChange={(value) =>
form.setValue("active", value === true, { shouldDirty: true })
}
/>
<Label htmlFor={`${prefix}-active`}>{t("active")}</Label>
</div>
<h3 className="font-medium">{t("interventionTemplates.steps")}</h3>
{steps.fields.map((step, index) => (
<TemplateStepFields
count={steps.fields.length}
form={form}
index={index}
key={step.fieldKey}
onMove={steps.move}
onRemove={steps.remove}
prefix={prefix}
/>
))}
<Button
disabled={steps.fields.length >= 100}
onClick={() => steps.append(newTemplateStep())}
type="button"
variant="outline"
>
{t("interventionTemplates.addStep")}
</Button>
</fieldset>
<InterventionTemplateFeedback
error={mutation.error}
invalid={Object.keys(form.formState.errors).length > 0}
/>
<InterventionTemplateHelp />
<FormActions>
<Button disabled={mutation.isPending} type="submit">
{t(mutation.isPending ? "saving" : "save")}
</Button>
</FormActions>
</form>
);
}

View File

@ -0,0 +1,38 @@
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
import { useTranslation } from "react-i18next";
import { InterventionTemplateApiError } from "./template-api";
export function InterventionTemplateHelp() {
const { t } = useTranslation("companies");
return (
<ContextHelp
description={t("interventionTemplates.helpDescription")}
examples={t("interventionTemplates.examples")}
examplesLabel={t("mail.examplesLabel")}
guidance={t("interventionTemplates.guidance")}
scopeNote={t("interventionTemplates.helpScope")}
title={t("interventionTemplates.help")}
/>
);
}
export function InterventionTemplateFeedback({
error,
invalid = false,
}: {
error: unknown;
invalid?: boolean;
}) {
const { t } = useTranslation("companies");
const code = invalid
? "INVALID_REQUEST"
: error instanceof InterventionTemplateApiError
? error.code
: "INTERNAL_ERROR";
return error || invalid ? (
<p className="text-sm text-destructive" role="alert">
{t(`interventionTemplates.errors.${code}`, {
defaultValue: t("interventionTemplates.errors.INTERNAL_ERROR"),
})}
</p>
) : null;
}

View File

@ -0,0 +1,88 @@
export const interventionTemplatesSpanish = {
title: "Plantillas de intervención",
new: "Nueva plantilla de intervención",
edit: "Editar plantilla de intervención",
description: "Descripción (opcional)",
steps: "Pasos",
addStep: "Añadir paso",
removeStep: "Quitar paso",
stepTitle: "Nombre del paso",
instructions: "Instrucciones (opcional)",
required: "Comprobación obligatoria antes de finalizar",
up: "Subir paso",
down: "Bajar paso",
global: "Global",
company: "De esta compañía",
copy: "Copiar plantilla",
copyOf: "Copia de",
filter: "Buscar plantilla",
showInactive: "Mostrar inactivas",
empty: "No hay plantillas que coincidan.",
scopeGlobal: "Estas plantillas son comunes a todas las compañías.",
scopeCompany:
"Consulta las plantillas globales y crea variantes propias de esta compañía. Las globales se editan desde Catálogos globales.",
help: "¿Para qué sirve una plantilla de intervención?",
helpDescription:
"Guarda un procedimiento repetible como una lista ordenada de pasos. Una intervención sencilla podrá registrarse sin plantilla.",
helpScope:
"La plantilla es un procedimiento: no contiene personas, cuentas ni datos concretos de una compañía.",
examples: "Sustitución de equipo, configuración de correo, baja de persona o revisión periódica.",
guidance:
"Añade instrucciones solo cuando ayuden al técnico. Marca como obligatorias únicamente las comprobaciones esenciales. Desactivar conserva el historial.",
errors: {
INVALID_REQUEST: "Revisa el nombre y los pasos. Debe haber entre 1 y 100 pasos con nombre.",
TEMPLATE_CHANGED:
"Otra persona ha modificado la plantilla. Cierra y vuelve a abrir el formulario para cargar su última versión.",
TEMPLATE_NAME_EXISTS: "Ya existe una plantilla con ese nombre en este ámbito.",
REQUEST_KEY_REUSED:
"La petición ya se utilizó con otros datos. Cierra y vuelve a abrir el formulario.",
TEMPLATE_NOT_FOUND: "La plantilla no está disponible en este ámbito.",
COMPANY_NOT_FOUND: "La compañía no está disponible.",
INTERNAL_ERROR: "No se ha podido completar la operación. Inténtalo de nuevo.",
FORBIDDEN: "Recarga la página para autorizar la operación.",
UNAUTHENTICATED: "La sesión ha caducado. Vuelve a entrar.",
},
};
export const interventionTemplatesEnglish = {
title: "Intervention templates",
new: "New intervention template",
edit: "Edit intervention template",
description: "Description (optional)",
steps: "Steps",
addStep: "Add step",
removeStep: "Remove step",
stepTitle: "Step name",
instructions: "Instructions (optional)",
required: "Required check before completion",
up: "Move step up",
down: "Move step down",
global: "Global",
company: "This company",
copy: "Copy template",
copyOf: "Copy of",
filter: "Find template",
showInactive: "Show inactive",
empty: "No matching templates.",
scopeGlobal: "These templates are shared by all companies.",
scopeCompany:
"Browse global templates and create company variants. Edit global templates in Global catalogs.",
help: "What is an intervention template for?",
helpDescription:
"Save a repeatable procedure as an ordered list of steps. Simple interventions can be recorded without a template.",
helpScope: "A template is a procedure: it contains no people, accounts or specific company data.",
examples: "Device replacement, email setup, employee departure or periodic checks.",
guidance:
"Add instructions when useful. Make only essential checks required. Deactivation retains history.",
errors: {
INVALID_REQUEST: "Review the name and steps. Include between 1 and 100 named steps.",
TEMPLATE_CHANGED:
"Someone else changed this template. Close and reopen the form to load the latest version.",
TEMPLATE_NAME_EXISTS: "A template with this name already exists in this scope.",
REQUEST_KEY_REUSED: "This request was already used with other data. Close and reopen the form.",
TEMPLATE_NOT_FOUND: "This template is unavailable in this scope.",
COMPANY_NOT_FOUND: "The company is unavailable.",
INTERNAL_ERROR: "The operation failed. Try again.",
FORBIDDEN: "Reload the page to authorize the operation.",
UNAUTHENTICATED: "Your session expired. Sign in again.",
},
};

View File

@ -0,0 +1,88 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
import { Input } from "@repo/shadcn-ui/components/input";
import { Label } from "@repo/shadcn-ui/components/label";
import { Textarea } from "@repo/shadcn-ui/components/textarea";
import type { UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { InterventionTemplateInput } from "../../common/intervention-templates";
export function TemplateStepFields({
form,
index,
count,
prefix,
onMove,
onRemove,
}: {
form: UseFormReturn<InterventionTemplateInput>;
index: number;
count: number;
prefix: string;
onMove: (from: number, to: number) => void;
onRemove: (index: number) => void;
}) {
const { t } = useTranslation("companies");
const id = `${prefix}-step-${index}`;
return (
<section
aria-label={`${t("interventionTemplates.steps")} ${index + 1}`}
className="space-y-3 rounded-lg border p-4"
>
<header className="flex flex-wrap items-center justify-between gap-2">
<span className="font-medium">{index + 1}</span>
<div className="flex gap-2">
<Button
disabled={index === 0}
onClick={() => onMove(index, index - 1)}
size="sm"
type="button"
variant="outline"
>
{t("interventionTemplates.up")}
</Button>
<Button
disabled={index === count - 1}
onClick={() => onMove(index, index + 1)}
size="sm"
type="button"
variant="outline"
>
{t("interventionTemplates.down")}
</Button>
<Button onClick={() => onRemove(index)} size="sm" type="button" variant="outline">
{t("interventionTemplates.removeStep")}
</Button>
</div>
</header>
<div className="space-y-2">
<Label htmlFor={`${id}-title`}>{t("interventionTemplates.stepTitle")}</Label>
<Input
id={`${id}-title`}
maxLength={200}
required
{...form.register(`steps.${index}.title`)}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-instructions`}>{t("interventionTemplates.instructions")}</Label>
<Textarea
id={`${id}-instructions`}
maxLength={4000}
rows={2}
{...form.register(`steps.${index}.instructions`)}
/>
</div>
<div className="flex gap-2 items-center">
<Checkbox
checked={form.watch(`steps.${index}.required`)}
id={`${id}-required`}
onCheckedChange={(value) =>
form.setValue(`steps.${index}.required`, value === true, { shouldDirty: true })
}
/>
<Label htmlFor={`${id}-required`}>{t("interventionTemplates.required")}</Label>
</div>
</section>
);
}

View File

@ -0,0 +1,160 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
import { 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 { useQuery } from "@tanstack/react-query";
import { useId, useState } from "react";
import { useTranslation } from "react-i18next";
import { interventionTemplateApi, interventionTemplateKeys } from "./template-api";
import { TemplateHistory, TemplateSteps } from "./template-detail";
import { InterventionTemplateForm, copyTemplateValues } from "./template-form";
import { InterventionTemplateFeedback } from "./template-help";
export function InterventionTemplatesPage({
scope = "global",
csrfToken,
}: {
scope?: string;
csrfToken: string;
}) {
const { t } = useTranslation("companies");
const prefix = useId();
const query = useQuery({
queryKey: interventionTemplateKeys.list(scope),
queryFn: () => interventionTemplateApi.list(scope),
});
const [editor, setEditor] = useState<{ id?: string; copyId?: string }>();
const [search, setSearch] = useState("");
const [showInactive, setShowInactive] = useState(true);
const templates = query.data?.templates ?? [];
const selected = templates.find((item) => item.id === editor?.id);
const copied = templates.find((item) => item.id === editor?.copyId);
const visible = templates.filter(
(item) =>
(showInactive || item.values.active) &&
item.values.name.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())
);
return (
<div className="space-y-5">
<header className="flex items-center justify-between gap-4">
<h2 className="text-xl font-semibold">{t("interventionTemplates.title")}</h2>
<Button disabled={!query.data || query.isError} onClick={() => setEditor({})}>
{t("interventionTemplates.new")}
</Button>
</header>
<p className="text-sm text-muted-foreground">
{t(
scope === "global"
? "interventionTemplates.scopeGlobal"
: "interventionTemplates.scopeCompany"
)}
</p>
<div className="flex flex-wrap gap-4 items-center">
<div>
<Label className="sr-only" htmlFor={`${prefix}-search`}>
{t("interventionTemplates.filter")}
</Label>
<Input
id={`${prefix}-search`}
onChange={(event) => setSearch(event.target.value)}
placeholder={t("interventionTemplates.filter")}
value={search}
/>
</div>
<div className="flex gap-2 items-center">
<Checkbox
checked={showInactive}
id={`${prefix}-inactive`}
onCheckedChange={(value) => setShowInactive(value === true)}
/>
<Label htmlFor={`${prefix}-inactive`}>{t("interventionTemplates.showInactive")}</Label>
</div>
</div>
{query.isPending && <p role="status">{t("loading")}</p>}
<InterventionTemplateFeedback error={query.error} />
{query.isError && (
<Button
onClick={() => {
query.refetch().catch(() => undefined);
}}
variant="outline"
>
{t("retry")}
</Button>
)}
{query.data && !query.isError && (
<>
{!visible.length && <p>{t("interventionTemplates.empty")}</p>}
{visible.map((template) => (
<section className="rounded-xl border p-5 space-y-4" key={template.id}>
<header className="flex flex-wrap justify-between gap-3">
<div>
<h3 className="font-medium">{template.values.name}</h3>
<p className="text-sm text-muted-foreground">
{t(
template.companyId
? "interventionTemplates.company"
: "interventionTemplates.global"
)}{" "}
· {t(template.values.active ? "active" : "inactive")}
</p>
</div>
<div className="flex gap-2">
{template.companyId === (scope === "global" ? null : scope) && (
<Button
onClick={() => setEditor({ id: template.id })}
size="sm"
variant="outline"
>
{t("edit")}
</Button>
)}
<Button
onClick={() => setEditor({ copyId: template.id })}
size="sm"
variant="outline"
>
{t("interventionTemplates.copy")}
</Button>
</div>
</header>
<TemplateSteps values={template.values} />
<TemplateHistory template={template} />
</section>
))}
</>
)}
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
setEditor(undefined);
}
}}
open={editor !== undefined}
title={t(selected ? "interventionTemplates.edit" : "interventionTemplates.new")}
wide
>
{editor && (
<InterventionTemplateForm
csrfToken={csrfToken}
initialValues={
copied
? copyTemplateValues(
copied,
`${t("interventionTemplates.copyOf")} ${copied.values.name}`
)
: undefined
}
key={editor.id ?? editor.copyId ?? "new"}
onSaved={() => setEditor(undefined)}
scope={scope}
template={selected}
/>
)}
</FormDialog>
</div>
);
}

View File

@ -0,0 +1,100 @@
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 type { InterventionTemplateDTO } from "../../common/intervention-templates";
import { companiesEnglish, companiesSpanish } from "../i18n";
import { interventionTemplateKeys } from "./template-api";
import { InterventionTemplateForm, copyTemplateValues } from "./template-form";
import { InterventionTemplatesPage } from "./templates-page";
const companyId = UniqueID.generateNewID().toPrimitive();
function template(): InterventionTemplateDTO {
const values = {
name: "Revisión",
description: "",
active: true,
steps: [
{
id: UniqueID.generateNewID().toPrimitive(),
title: "Comprobar",
instructions: "",
required: true,
},
],
};
return {
id: UniqueID.generateNewID().toPrimitive(),
companyId: null,
values,
history: [
{
id: UniqueID.generateNewID().toPrimitive(),
values,
actorId: null,
actorName: "Metis Desk",
recordedAt: "2026-10-07T10:00:00.000Z",
},
],
};
}
async function render(node: ReactNode, language = "es") {
const i18n = createInstance();
await i18n.init({
lng: language,
resources: { es: { companies: companiesSpanish }, en: { companies: companiesEnglish } },
});
const client = new QueryClient();
const global = template();
client.setQueryData(interventionTemplateKeys.list("global"), { templates: [global] });
client.setQueryData(interventionTemplateKeys.list(companyId), {
templates: [global, { ...template(), companyId }],
});
try {
return renderToStaticMarkup(
<I18nextProvider i18n={i18n}>
<QueryClientProvider client={client}>{node}</QueryClientProvider>
</I18nextProvider>
);
} finally {
client.clear();
}
}
test("plantillas de intervención: listado sin formulario, es/en, globales no editables desde compañía", async () => {
const global = await render(<InterventionTemplatesPage csrfToken="test" />);
assert.ok(global.includes("Plantillas de intervención"));
assert.equal(global.includes("<form"), false);
const scoped = await render(<InterventionTemplatesPage csrfToken="test" scope={companyId} />);
assert.equal((scoped.match(/>Editar</g) ?? []).length, 1);
const english = await render(<InterventionTemplatesPage csrfToken="test" />, "en");
assert.ok(english.includes("Intervention templates"));
});
test("plantillas de intervención: ayuda antes del pie y controles de pasos", async () => {
const html = await render(
<InterventionTemplateForm csrfToken="test" onSaved={() => undefined} scope="global" />
);
assert.ok(html.includes("Añadir paso"));
assert.ok(html.includes("Subir paso"));
assert.ok(html.includes("Comprobación obligatoria"));
assert.ok(
html.indexOf("¿Para qué sirve una plantilla de intervención?") < html.indexOf("Guardar")
);
});
test("plantillas de intervención: copia activa y pasos con identidades independientes", () => {
const source = template();
const copy = copyTemplateValues(
{ ...source, values: { ...source.values, active: false } },
"Copia"
);
assert.equal(copy.active, true);
assert.equal(copy.name, "Copia");
assert.notEqual(copy.steps[0]?.id, source.values.steps[0]?.id);
assert.equal(copy.steps[0]?.title, source.values.steps[0]?.title);
});

View File

@ -0,0 +1,44 @@
import { z } from "zod/v4";
import {
InterventionListSchema,
InterventionLookupsSchema,
InterventionResponseSchema,
type SaveInterventionInput,
} from "../../common/interventions";
export const interventionKeys = {
list: (companyId?: string) => ["interventions", companyId ?? "global"],
lookups: (companyId: string) => ["intervention-lookups", companyId],
};
export class InterventionApiError extends Error {
constructor(public readonly code: string) {
super(code);
}
}
async function request(path: string, input?: SaveInterventionInput, token?: string) {
const response = await fetch(`/api/interventions${path}`, {
method: input ? "POST" : "GET",
credentials: "same-origin",
headers: input ? { "content-type": "application/json", "x-csrf-token": token ?? "" } : {},
...(input ? { body: JSON.stringify(input) } : {}),
});
const data: unknown = await response.json();
if (!response.ok) {
const error = z.object({ code: z.string() }).safeParse(data);
throw new InterventionApiError(error.success ? error.data.code : "INTERNAL_ERROR");
}
return data;
}
export const interventionApi = {
async list(companyId?: string) {
return InterventionListSchema.parse(await request(companyId ? `/${companyId}` : ""));
},
async lookups(companyId: string) {
return InterventionLookupsSchema.parse(await request(`/${companyId}/lookups`));
},
async save(companyId: string, input: SaveInterventionInput, token: string, id?: string) {
return InterventionResponseSchema.parse(
await request(`/${companyId}${id ? `/${id}/update` : ""}`, input, token)
);
},
};

View File

@ -0,0 +1,186 @@
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import type {
InterventionDTO,
InterventionInput,
InterventionListDTO,
} from "../../common/interventions";
import type { TicketLookupsDTO } from "../../common/tickets";
import { ticketTargetOptions } from "../tickets/ticket-targets";
import { interventionApi, interventionKeys } from "./intervention-api";
export function stepCompletion(intervention: InterventionDTO, stepId: string) {
let completed: InterventionDTO["history"][number] | undefined;
let previousState: string | undefined;
for (const revision of intervention.history) {
const step = revision.values.steps.find((item) => item.id === stepId);
if (step?.state === "done" && previousState !== "done") {
completed = revision;
}
if (step?.state !== "done") {
completed = undefined;
}
previousState = step?.state;
}
return completed;
}
function InterventionSnapshot({
values,
data,
lookups,
}: {
values: InterventionInput;
data: InterventionListDTO;
lookups: TicketLookupsDTO | undefined;
}) {
const { t, i18n } = useTranslation("companies");
const date = (value: string | null) =>
value ? new Date(value).toLocaleString(i18n.language) : "—";
return (
<div className="space-y-3">
<p className="font-medium">
{values.subject} · {t(`interventions.states.${values.state}`)}
</p>
<dl className="grid sm:grid-cols-2 gap-2 text-sm">
<div>
<dt>{t("interventions.assignee")}</dt>
<dd>
{data.technicians.find((item) => item.id === values.assigneeId)?.name ??
t("interventions.unassigned")}
</dd>
</div>
<div>
<dt>{t("interventions.modality")}</dt>
<dd>{t(`interventions.modalities.${values.modality}`)}</dd>
</div>
<div>
<dt>{t("interventions.plannedDate")}</dt>
<dd>{values.plannedDate ?? "—"}</dd>
</div>
<div>
<dt>{t("interventions.minutes")}</dt>
<dd>{values.minutes ?? t("interventions.unknownTime")}</dd>
</div>
<div>
<dt>{t("interventions.startedAt")}</dt>
<dd>{date(values.startedAt)}</dd>
</div>
<div>
<dt>{t("interventions.endedAt")}</dt>
<dd>{date(values.endedAt)}</dd>
</div>
</dl>
{(["diagnosis", "workPerformed", "resolution"] as const).map(
(field) =>
values[field] && (
<div key={field}>
<h4 className="font-medium text-sm">{t(`interventions.${field}`)}</h4>
<p className="whitespace-pre-wrap break-words text-sm">{values[field]}</p>
</div>
)
)}
{values.affected.length > 0 && (
<ul className="text-sm">
{values.affected.map((target) => (
<li key={target.kind + target.targetId}>
{t(`tickets.${target.kind}`)} ·{" "}
{lookups
? (ticketTargetOptions(lookups, target.kind).find(
(item) => item.id === target.targetId
)?.name ?? target.targetId)
: target.targetId}
</li>
))}
</ul>
)}
{values.steps.length > 0 && (
<ol className="list-decimal pl-5 space-y-2 text-sm">
{values.steps.map((step) => (
<li key={step.id}>
<p>
{step.title} · {t(`interventions.stepStates.${step.state}`)}
{step.required ? ` · ${t("interventionTemplates.required")}` : ""}
</p>
{step.instructions && <p className="whitespace-pre-wrap">{step.instructions}</p>}
{step.notes && <p className="whitespace-pre-wrap">{step.notes}</p>}
</li>
))}
</ol>
)}
</div>
);
}
export function InterventionDetail({
intervention,
data,
}: {
intervention: InterventionDTO;
data: InterventionListDTO;
}) {
const { t, i18n } = useTranslation("companies");
const lookups = useQuery({
queryKey: interventionKeys.lookups(intervention.companyId),
queryFn: () => interventionApi.lookups(intervention.companyId),
});
return (
<div className="space-y-4">
<p>
{intervention.reference} · {intervention.companyName} ·{" "}
{intervention.ticketReference ?? t("interventions.internal")}
</p>
<InterventionSnapshot data={data} lookups={lookups.data} values={intervention.values} />
{intervention.values.steps.map((step) => {
const completed = stepCompletion(intervention, step.id);
return completed ? (
<p className="text-xs text-muted-foreground" key={step.id}>
{step.title}:{" "}
{t("interventions.completed", {
actor: completed.actorName,
date: new Date(completed.recordedAt).toLocaleString(i18n.language),
})}
</p>
) : null;
})}
{intervention.procedure && (
<details>
<summary className="cursor-pointer text-sm">
{t("interventions.source")}: {intervention.procedure.values.name}
</summary>
<p className="text-sm whitespace-pre-wrap">{intervention.procedure.values.description}</p>
<ol className="list-decimal pl-5 text-sm">
{intervention.procedure.values.steps.map((step) => (
<li key={step.id}>
{step.title}
{step.instructions && <p className="whitespace-pre-wrap">{step.instructions}</p>}
</li>
))}
</ol>
</details>
)}
<details>
<summary className="cursor-pointer font-medium">
{t("interventions.history")} ({intervention.history.length})
</summary>
<ol className="space-y-3 mt-3">
{[...intervention.history].reverse().map((revision) => (
<li className="rounded-md border p-3 space-y-2" key={revision.id}>
<p className="text-sm">
{new Date(revision.recordedAt).toLocaleString(i18n.language)} · {revision.actorName}
</p>
<p className="text-sm">
{t("interventions.ticket")}:{" "}
{lookups.data?.tickets.find((item) => item.id === revision.values.ticketId)
?.reference ??
revision.values.ticketId ??
t("interventions.internal")}
</p>
<InterventionSnapshot data={data} lookups={lookups.data} values={revision.values} />
</li>
))}
</ol>
</details>
</div>
);
}

View File

@ -0,0 +1,122 @@
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 { Textarea } from "@repo/shadcn-ui/components/textarea";
import type { UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next";
import {
type InterventionInput,
InterventionModalitySchema,
InterventionStateSchema,
} from "../../common/interventions";
export function InterventionWorkFields({
form,
prefix,
}: {
form: UseFormReturn<InterventionInput>;
prefix: string;
}) {
const { t } = useTranslation("companies");
return (
<>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`${prefix}-state`}>{t("interventions.state")}</Label>
<SelectWithAction
id={`${prefix}-state`}
label={t("interventions.state")}
onValueChange={(value) => {
const parsed = InterventionStateSchema.safeParse(value);
if (parsed.success) {
form.setValue("state", parsed.data);
}
}}
options={InterventionStateSchema.options.map((value) => ({
value,
label: t(`interventions.states.${value}`),
}))}
placeholder={t("tickets.choose")}
value={form.watch("state")}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-modality`}>{t("interventions.modality")}</Label>
<SelectWithAction
id={`${prefix}-modality`}
label={t("interventions.modality")}
onValueChange={(value) => {
const parsed = InterventionModalitySchema.safeParse(value);
if (parsed.success) {
form.setValue("modality", parsed.data);
}
}}
options={InterventionModalitySchema.options.map((value) => ({
value,
label: t(`interventions.modalities.${value}`),
}))}
placeholder={t("tickets.choose")}
value={form.watch("modality")}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-plannedDate`}>{t("interventions.plannedDate")}</Label>
<Input
id={`${prefix}-plannedDate`}
type="date"
{...form.register("plannedDate", { setValueAs: (value: string) => value || null })}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-minutes`}>{t("interventions.minutes")}</Label>
<Input
id={`${prefix}-minutes`}
max={1000000}
min={0}
step={1}
type="number"
{...form.register("minutes", {
setValueAs: (value: string) =>
value === "" || value === null ? null : Number(value),
})}
/>
</div>
{(["startedAt", "endedAt"] as const).map((field) => (
<div className="space-y-2" key={field}>
<Label htmlFor={`${prefix}-${field}`}>{t(`interventions.${field}`)}</Label>
<Input
id={`${prefix}-${field}`}
onChange={(event) =>
form.setValue(
field,
event.target.value ? new Date(event.target.value).toISOString() : null
)
}
type="datetime-local"
value={toLocalDateTime(form.watch(field))}
/>
</div>
))}
</div>
<p className="text-xs text-muted-foreground">{t("interventions.timeHelp")}</p>
{(["diagnosis", "workPerformed", "resolution"] as const).map((field) => (
<div className="space-y-2" key={field}>
<Label htmlFor={`${prefix}-${field}`}>{t(`interventions.${field}`)}</Label>
<Textarea
id={`${prefix}-${field}`}
maxLength={10000}
rows={3}
{...form.register(field)}
/>
</div>
))}
</>
);
}
export function toLocalDateTime(value: string | null) {
if (!value) {
return "";
}
const date = new Date(value);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}T${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
}

View File

@ -0,0 +1,397 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { UniqueID } from "@repo/rdx-ddd";
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, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useId, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import {
type InterventionDTO,
type InterventionInput,
InterventionInputSchema,
type InterventionListDTO,
type SaveInterventionInput,
} from "../../common/interventions";
import type { TicketDTO } from "../../common/tickets";
import { InterventionTemplateForm } from "../intervention-templates/template-form";
import { TicketForm } from "../tickets/ticket-form";
import { TicketQuickForms } from "../tickets/ticket-quick-forms";
import { TicketTargets } from "../tickets/ticket-targets";
import { InterventionApiError, interventionApi, interventionKeys } from "./intervention-api";
import { InterventionWorkFields } from "./intervention-fields";
import { InterventionFeedback, InterventionHelp } from "./intervention-help";
import { InterventionProcedureFields } from "./intervention-procedure-fields";
import { InterventionSteps } from "./intervention-steps";
export function emptyIntervention(ticket?: TicketDTO): InterventionInput {
return {
subject: ticket?.values.subject ?? "",
ticketId: ticket?.id ?? null,
assigneeId: ticket?.values.assigneeId ?? null,
state: "pending",
modality: "remote",
plannedDate: null,
startedAt: null,
endedAt: null,
minutes: null,
diagnosis: "",
workPerformed: "",
resolution: "",
affected: ticket?.values.affected.map((item) => ({ ...item })) ?? [],
steps: [],
};
}
export function InterventionForm({
scope,
intervention,
initialTicket,
data,
csrfToken,
onSaved,
}: {
scope?: string;
intervention?: InterventionDTO;
initialTicket?: TicketDTO;
data: InterventionListDTO;
csrfToken: string;
onSaved: () => void;
}) {
const { t } = useTranslation("companies");
const prefix = useId();
const client = useQueryClient();
const [companyId, setCompanyId] = useState(
intervention?.companyId ?? initialTicket?.companyId ?? scope ?? ""
);
const [requestKey] = useState(() => UniqueID.generateNewID().toPrimitive());
const [quick, setQuick] = useState<"company" | "ticket" | "template">();
const [selectedTemplate, setSelectedTemplate] = useState("");
const [applied, setApplied] = useState<SaveInterventionInput["template"]>(null);
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const form = useForm<InterventionInput>({
resolver: zodResolver(InterventionInputSchema),
defaultValues: intervention?.values ?? emptyIntervention(initialTicket),
});
const values = form.watch();
const lookups = useQuery({
queryKey: interventionKeys.lookups(companyId),
queryFn: () => interventionApi.lookups(companyId),
enabled: Boolean(companyId),
});
const mutation = useMutation({
mutationFn: (input: InterventionInput) =>
interventionApi.save(
companyId,
{
values: input,
requestKey,
expectedRevisionId: intervention?.history.at(-1)?.id ?? null,
template: intervention ? null : applied,
},
csrfToken,
intervention?.id
),
onSuccess: async () => {
await client.invalidateQueries({ queryKey: ["interventions"] });
if (mounted.current) {
onSaved();
}
},
onError: async (error) => {
if (error instanceof InterventionApiError && error.code === "INTERVENTION_CHANGED") {
await client.invalidateQueries({ queryKey: ["interventions"] });
}
if (error instanceof InterventionApiError && error.code === "TEMPLATE_CHANGED") {
await client.invalidateQueries({ queryKey: interventionKeys.lookups(companyId) });
}
},
});
const changeCompany = (id: string) => {
setCompanyId(id);
form.setValue("ticketId", null);
form.setValue("affected", []);
form.setValue("steps", []);
setApplied(null);
setSelectedTemplate("");
};
const applyTemplate = () => {
const template = lookups.data?.templates.find(
(item) => item.id === selectedTemplate && item.values.active
);
if (!template) {
setApplied(null);
form.setValue("steps", []);
return;
}
setApplied({ id: template.id, revisionId: template.history.at(-1)?.id ?? "" });
form.setValue(
"steps",
template.values.steps.map((step) => ({
...step,
id: UniqueID.generateNewID().toPrimitive(),
state: "pending",
notes: "",
}))
);
};
const selectTicket = (id: string) => {
form.setValue("ticketId", id || null);
const ticket = lookups.data?.tickets.find((item) => item.id === id);
if (!ticket) {
return;
}
if (!form.getValues("subject").trim()) {
form.setValue("subject", ticket.values.subject);
}
if (!form.getValues("affected").length) {
form.setValue("affected", ticket.values.affected);
}
if (
!form.getValues("assigneeId") &&
data.technicians.some((item) => item.id === ticket.values.assigneeId && item.active)
) {
form.setValue("assigneeId", ticket.values.assigneeId);
}
};
return (
<>
<form className="space-y-5" onSubmit={form.handleSubmit((input) => mutation.mutate(input))}>
<fieldset className="space-y-4" disabled={mutation.isPending}>
<InterventionCompanyField
companies={data.companies}
companyId={companyId}
fixed={isCompanyFixed(scope, intervention, initialTicket)}
onChange={changeCompany}
onNew={() => setQuick("company")}
prefix={prefix}
/>
<div className="space-y-2">
<Label htmlFor={`${prefix}-subject`}>{t("interventions.subject")}</Label>
<Input
id={`${prefix}-subject`}
maxLength={200}
required
{...form.register("subject")}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`${prefix}-ticket`}>{t("interventions.ticket")}</Label>
<SelectWithAction
action={{
label: t("tickets.new"),
onClick: () => setQuick("ticket"),
disabled: !(companyId && lookups.data),
}}
disabled={!lookups.data}
id={`${prefix}-ticket`}
label={t("interventions.ticket")}
onValueChange={selectTicket}
options={
lookups.data?.tickets.map((item) => ({
value: item.id,
label: `${item.reference} · ${item.values.subject}`,
})) ?? []
}
placeholder={t("interventions.internal")}
value={values.ticketId ?? ""}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-assignee`}>{t("interventions.assignee")}</Label>
<SelectWithAction
id={`${prefix}-assignee`}
label={t("interventions.assignee")}
onValueChange={(id) => form.setValue("assigneeId", id || null)}
options={data.technicians
.filter((item) => item.active || item.id === intervention?.values.assigneeId)
.map((item) => ({ value: item.id, label: item.name }))}
placeholder={t("interventions.unassigned")}
value={values.assigneeId ?? ""}
/>
</div>
</div>
<InterventionWorkFields form={form} prefix={prefix} />
{companyId && lookups.isPending && <p role="status">{t("loading")}</p>}
<InterventionFeedback error={lookups.error} />
{lookups.isError && (
<Button
onClick={() => {
lookups.refetch().catch(() => undefined);
}}
type="button"
variant="outline"
>
{t("retry")}
</Button>
)}
{lookups.data && (
<TicketTargets
companyId={companyId}
csrfToken={csrfToken}
lookupKey={interventionKeys.lookups(companyId)}
lookups={lookups.data}
onChange={(affected) => form.setValue("affected", affected)}
values={values.affected}
/>
)}
<InterventionProcedureFields
applied={applied}
intervention={intervention}
lookups={lookups.data}
onApply={applyTemplate}
onNewTemplate={() => setQuick("template")}
onSelect={setSelectedTemplate}
prefix={prefix}
selectedTemplate={selectedTemplate}
/>
<InterventionSteps form={form} prefix={prefix} />
</fieldset>
<InterventionFeedback
error={mutation.error}
invalid={Object.keys(form.formState.errors).length > 0}
/>
<InterventionHelp />
<FormActions>
<Button
disabled={mutation.isPending || !companyId || !lookups.data || lookups.isError}
type="submit"
>
{t(mutation.isPending ? "saving" : "save")}
</Button>
</FormActions>
</form>
<TicketQuickForms
companyId={companyId}
csrfToken={csrfToken}
kind={quick === "company" ? "company" : undefined}
onClose={() => setQuick(undefined)}
onCompany={(id) => {
client
.invalidateQueries({ queryKey: ["interventions"] })
.then(() => {
changeCompany(id);
setQuick(undefined);
})
.catch(() => undefined);
}}
onPerson={() => undefined}
/>
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
setQuick(undefined);
}
}}
open={quick === "ticket"}
title={t("tickets.new")}
wide
>
{quick === "ticket" && (
<TicketForm
csrfToken={csrfToken}
data={{
companies: data.companies,
technicians: data.technicians,
tickets: lookups.data?.tickets ?? [],
}}
onSaved={(id) => {
client
.invalidateQueries({ queryKey: interventionKeys.lookups(companyId) })
.then(() => {
form.setValue("ticketId", id);
setQuick(undefined);
})
.catch(() => undefined);
}}
scope={companyId}
/>
)}
</FormDialog>
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
setQuick(undefined);
}
}}
open={quick === "template"}
title={t("interventions.newTemplate")}
wide
>
{quick === "template" && (
<InterventionTemplateForm
csrfToken={csrfToken}
onSaved={(id) => {
client
.invalidateQueries({ queryKey: interventionKeys.lookups(companyId) })
.then(() => {
setSelectedTemplate(id);
setQuick(undefined);
})
.catch(() => undefined);
}}
scope={companyId}
/>
)}
</FormDialog>
</>
);
}
function isCompanyFixed(
scope: string | undefined,
intervention: InterventionDTO | undefined,
ticket: TicketDTO | undefined
) {
return Boolean(scope || intervention || ticket);
}
function InterventionCompanyField({
prefix,
companyId,
companies,
fixed,
onChange,
onNew,
}: {
prefix: string;
companyId: string;
companies: InterventionListDTO["companies"];
fixed: boolean;
onChange: (id: string) => void;
onNew: () => void;
}) {
const { t } = useTranslation("companies");
return (
<div className="space-y-2">
<Label htmlFor={prefix}>{t("tickets.company")}</Label>
{fixed ? (
<p>{companies.find((item) => item.id === companyId)?.name ?? companyId}</p>
) : (
<SelectWithAction
action={{ label: t("newCompany"), onClick: onNew }}
id={prefix}
label={t("tickets.company")}
onValueChange={onChange}
options={companies.map((item) => ({ value: item.id, label: item.name }))}
placeholder={t("tickets.choose")}
value={companyId}
/>
)}
</div>
);
}

View File

@ -0,0 +1,38 @@
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
import { useTranslation } from "react-i18next";
import { InterventionApiError } from "./intervention-api";
export function InterventionHelp() {
const { t } = useTranslation("companies");
return (
<ContextHelp
description={t("interventions.helpDescription")}
examples={t("interventions.examples")}
examplesLabel={t("mail.examplesLabel")}
guidance={t("interventions.guidance")}
scopeNote={t("interventions.scope")}
title={t("interventions.help")}
/>
);
}
export function InterventionFeedback({
error,
invalid = false,
}: {
error: unknown;
invalid?: boolean;
}) {
const { t } = useTranslation("companies");
const code = invalid
? "INVALID_REQUEST"
: error instanceof InterventionApiError
? error.code
: "INTERNAL_ERROR";
return error || invalid ? (
<p className="text-sm text-destructive" role="alert">
{t(`interventions.errors.${code}`, {
defaultValue: t("interventions.errors.INTERNAL_ERROR"),
})}
</p>
) : null;
}

View File

@ -0,0 +1,171 @@
export const interventionsSpanish = {
resetFilters: "Limpiar filtros",
title: "Intervenciones",
global: "Todas las intervenciones",
new: "Nueva intervención",
edit: "Editar intervención",
details: "Ficha de intervención",
subject: "Asunto",
state: "Estado",
ticket: "Ticket (opcional)",
internal: "Intervención interna, sin ticket",
fromTicket: "Crear intervención",
assignee: "Técnico responsable",
unassigned: "Sin asignar",
modality: "Modalidad",
states: {
pending: "Pendiente",
in_progress: "En curso",
finished: "Finalizada",
cancelled: "Cancelada",
},
modalities: { remote: "Remota", onsite: "Presencial", other: "Otra" },
plannedDate: "Fecha prevista (opcional)",
startedAt: "Inicio real (opcional)",
endedAt: "Fin real (opcional)",
minutes: "Tiempo empleado (minutos, opcional)",
unknownTime: "Sin registrar",
timeHelp: "El tiempo empleado es manual y puede diferir del intervalo entre inicio y fin.",
diagnosis: "Diagnóstico (opcional)",
workPerformed: "Trabajo realizado",
resolution: "Resultado / resolución (opcional)",
template: "Plantilla (opcional)",
noTemplate: "Sin plantilla",
apply: "Aplicar plantilla",
newTemplate: "Nueva plantilla",
templateNotice:
"Aplicar sustituye los pasos actuales. La copia se puede adaptar y conserva la versión original de la plantilla.",
source: "Plantilla de origen",
steps: "Pasos",
progress: "{{done}} de {{total}} pasos hechos",
addStep: "Añadir paso",
removeStep: "Quitar paso",
notes: "Observaciones / motivo de no aplicación",
stepStates: {
pending: "Pendiente",
in_progress: "En curso",
done: "Hecho",
not_applicable: "No aplica",
},
search: "Buscar intervención",
allCompanies: "Todas las compañías",
allStates: "Todos los estados",
allAssignees: "Todos los técnicos",
dateFrom: "Prevista desde",
dateTo: "Prevista hasta",
empty: "No hay intervenciones que coincidan.",
loading: "Cargando intervenciones…",
history: "Historial de la intervención",
completed: "Completado por {{actor}} · {{date}}",
help: "¿Para qué sirve una intervención?",
helpDescription:
"Registra el trabajo técnico realizado o previsto para una compañía, con ticket o por iniciativa interna.",
scope:
"Puede ser una actuación sencilla sin pasos. Las plantillas copian un procedimiento; no configuran equipos ni retiran accesos automáticamente.",
examples: "Reconfigurar un correo, resolver un atasco de impresora o sustituir un portátil.",
guidance:
"Para finalizar, indica técnico y trabajo realizado. Los pasos obligatorios deben estar hechos o no aplicar con un motivo. Finalizar no cierra el ticket.",
errors: {
INVALID_REQUEST:
"Revisa los datos. En curso o finalizada requiere técnico. Para finalizar indica el trabajo y completa los pasos obligatorios (o justifica que no aplican). Las fechas deben estar ordenadas y los minutos ser positivos o cero.",
INTERVENTION_NOT_FOUND: "La intervención no está disponible en esta compañía.",
INTERVENTION_CHANGED:
"Otra persona ha modificado la intervención. Cierra y vuelve a abrir el formulario.",
TICKET_NOT_FOUND: "El ticket no pertenece a esta compañía.",
TEMPLATE_UNAVAILABLE: "La plantilla no está activa o no está disponible para esta compañía.",
TEMPLATE_CHANGED: "La plantilla cambió. Recarga sus opciones y vuelve a aplicarla.",
TARGET_UNAVAILABLE: "Algún elemento afectado no pertenece a la compañía o está inactivo.",
ASSIGNEE_UNAVAILABLE: "El técnico no está disponible para una nueva asignación.",
REQUEST_KEY_REUSED:
"Esta petición ya se utilizó con otros datos. Cierra y vuelve a abrir el formulario.",
COMPANY_NOT_FOUND: "La compañía no está disponible.",
UNAUTHENTICATED: "La sesión ha caducado. Vuelve a entrar.",
FORBIDDEN: "Recarga la página para autorizar la operación.",
INTERNAL_ERROR: "No se ha podido completar la operación. Inténtalo de nuevo.",
},
};
export const interventionsEnglish = {
resetFilters: "Clear filters",
title: "Interventions",
global: "All interventions",
new: "New intervention",
edit: "Edit intervention",
details: "Intervention details",
subject: "Subject",
state: "State",
ticket: "Ticket (optional)",
internal: "Internal intervention, no ticket",
fromTicket: "Create intervention",
assignee: "Responsible technician",
unassigned: "Unassigned",
modality: "Modality",
states: {
pending: "Pending",
in_progress: "In progress",
finished: "Finished",
cancelled: "Cancelled",
},
modalities: { remote: "Remote", onsite: "On site", other: "Other" },
plannedDate: "Planned date (optional)",
startedAt: "Actual start (optional)",
endedAt: "Actual end (optional)",
minutes: "Time spent (minutes, optional)",
unknownTime: "Not recorded",
timeHelp: "Time spent is recorded manually and may differ from elapsed time.",
diagnosis: "Diagnosis (optional)",
workPerformed: "Work performed",
resolution: "Result / resolution (optional)",
template: "Template (optional)",
noTemplate: "No template",
apply: "Apply template",
newTemplate: "New template",
templateNotice:
"Applying replaces current steps. The copy can be adapted and retains the original template version.",
source: "Source template",
steps: "Steps",
progress: "{{done}} of {{total}} steps done",
addStep: "Add step",
removeStep: "Remove step",
notes: "Notes / reason for not applying",
stepStates: {
pending: "Pending",
in_progress: "In progress",
done: "Done",
not_applicable: "Not applicable",
},
search: "Find intervention",
allCompanies: "All companies",
allStates: "All states",
allAssignees: "All technicians",
dateFrom: "Planned from",
dateTo: "Planned until",
empty: "No matching interventions.",
loading: "Loading interventions…",
history: "Intervention history",
completed: "Completed by {{actor}} · {{date}}",
help: "What is an intervention for?",
helpDescription:
"Record technical work planned or performed for a company, linked to a ticket or initiated internally.",
scope:
"Simple work can have no steps. Templates copy a procedure; they do not configure devices or remove access automatically.",
examples: "Reconfigure email, clear a printer jam or replace a laptop.",
guidance:
"To finish, enter a technician and work performed. Required steps must be done or marked not applicable with a reason. Finishing does not close the ticket.",
errors: {
INVALID_REQUEST:
"Review the data. In progress or finished requires a technician. To finish, describe the work and complete required checks (or explain why they do not apply). Dates must be ordered and minutes nonnegative integers.",
INTERVENTION_NOT_FOUND: "The intervention is unavailable in this company.",
INTERVENTION_CHANGED: "Someone changed this intervention. Close and reopen the form.",
TICKET_NOT_FOUND: "The ticket does not belong to this company.",
TEMPLATE_UNAVAILABLE: "The template is inactive or unavailable for this company.",
TEMPLATE_CHANGED: "The template changed. Refresh the options and apply it again.",
TARGET_UNAVAILABLE: "An affected item is inactive or does not belong to the company.",
ASSIGNEE_UNAVAILABLE: "The technician is unavailable for a new assignment.",
REQUEST_KEY_REUSED:
"This request was already used with different data. Close and reopen the form.",
COMPANY_NOT_FOUND: "The company is unavailable.",
UNAUTHENTICATED: "Your session expired. Sign in again.",
FORBIDDEN: "Reload the page to authorize the operation.",
INTERNAL_ERROR: "The operation failed. Try again.",
},
};

View File

@ -0,0 +1,74 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { Label } from "@repo/shadcn-ui/components/label";
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
import { useTranslation } from "react-i18next";
import type {
InterventionDTO,
InterventionLookupsDTO,
SaveInterventionInput,
} from "../../common/interventions";
export function InterventionProcedureFields({
prefix,
intervention,
lookups,
selectedTemplate,
onSelect,
onApply,
onNewTemplate,
applied,
}: {
prefix: string;
intervention: InterventionDTO | undefined;
lookups: InterventionLookupsDTO | undefined;
selectedTemplate: string;
onSelect: (value: string) => void;
onApply: () => void;
onNewTemplate: () => void;
applied: SaveInterventionInput["template"];
}) {
const { t } = useTranslation("companies");
return (
<>
{" "}
{!intervention && (
<section className="space-y-2">
<Label htmlFor={`${prefix}-template`}>{t("interventions.template")}</Label>
<div className="flex gap-2">
<div className="flex-1">
<SelectWithAction
action={{
label: t("interventions.newTemplate"),
onClick: onNewTemplate,
disabled: !lookups,
}}
disabled={!lookups}
id={`${prefix}-template`}
label={t("interventions.template")}
onValueChange={onSelect}
options={
lookups?.templates
.filter((item) => item.values.active)
.map((item) => ({ value: item.id, label: item.values.name })) ?? []
}
placeholder={t("interventions.noTemplate")}
value={selectedTemplate}
/>
</div>
<Button disabled={!lookups} onClick={onApply} type="button" variant="outline">
{t("interventions.apply")}
</Button>
</div>
<p className="text-xs text-muted-foreground">{t("interventions.templateNotice")}</p>
</section>
)}
{(intervention?.procedure || applied) && (
<p className="text-sm">
{t("interventions.source")}:{" "}
{intervention?.procedure?.values.name ??
lookups?.templates.find((item) => item.id === applied?.id)?.values.name}
</p>
)}
</>
);
}

View File

@ -0,0 +1,145 @@
import { UniqueID } from "@repo/rdx-ddd";
import { Button } from "@repo/shadcn-ui/components/button";
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
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 { Textarea } from "@repo/shadcn-ui/components/textarea";
import { type UseFormReturn, useFieldArray } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { type InterventionInput, InterventionStepStateSchema } from "../../common/interventions";
export function InterventionSteps({
form,
prefix,
}: {
form: UseFormReturn<InterventionInput>;
prefix: string;
}) {
const { t } = useTranslation("companies");
const array = useFieldArray({ control: form.control, name: "steps", keyName: "fieldKey" });
const values = form.watch("steps");
return (
<section className="space-y-3">
<header className="flex justify-between items-center">
<h3 className="font-medium">{t("interventions.steps")}</h3>
<span className="text-sm text-muted-foreground">
{t("interventions.progress", {
done: values.filter((item) => item.state === "done").length,
total: values.length,
})}
</span>
</header>
{array.fields.map((field, index) => {
const id = `${prefix}-step-${index}`;
return (
<section className="space-y-3 rounded-lg border p-4" key={field.fieldKey}>
<header className="flex flex-wrap justify-between gap-2">
<span>{index + 1}</span>
<div className="flex gap-2">
<Button
disabled={index === 0}
onClick={() => array.move(index, index - 1)}
size="sm"
type="button"
variant="outline"
>
{t("interventionTemplates.up")}
</Button>
<Button
disabled={index === array.fields.length - 1}
onClick={() => array.move(index, index + 1)}
size="sm"
type="button"
variant="outline"
>
{t("interventionTemplates.down")}
</Button>
<Button
onClick={() => array.remove(index)}
size="sm"
type="button"
variant="outline"
>
{t("interventions.removeStep")}
</Button>
</div>
</header>
<div className="space-y-2">
<Label htmlFor={`${id}-title`}>{t("interventionTemplates.stepTitle")}</Label>
<Input
id={`${id}-title`}
maxLength={200}
required
{...form.register(`steps.${index}.title`)}
/>
</div>
<details>
<summary className="text-sm cursor-pointer">
{t("interventionTemplates.instructions")}
</summary>
<Textarea
aria-label={t("interventionTemplates.instructions")}
maxLength={4000}
rows={2}
{...form.register(`steps.${index}.instructions`)}
/>
</details>
<div className="flex gap-2 items-center">
<Checkbox
checked={form.watch(`steps.${index}.required`)}
id={`${id}-required`}
onCheckedChange={(value) =>
form.setValue(`steps.${index}.required`, value === true)
}
/>
<Label htmlFor={`${id}-required`}>{t("interventionTemplates.required")}</Label>
</div>
<SelectWithAction
id={`${id}-state`}
label={t("interventions.state")}
onValueChange={(value) => {
const parsed = InterventionStepStateSchema.safeParse(value);
if (parsed.success) {
form.setValue(`steps.${index}.state`, parsed.data);
}
}}
options={InterventionStepStateSchema.options.map((value) => ({
value,
label: t(`interventions.stepStates.${value}`),
}))}
placeholder={t("tickets.choose")}
value={form.watch(`steps.${index}.state`)}
/>
<div className="space-y-2">
<Label htmlFor={`${id}-notes`}>{t("interventions.notes")}</Label>
<Textarea
id={`${id}-notes`}
maxLength={4000}
rows={2}
{...form.register(`steps.${index}.notes`)}
/>
</div>
</section>
);
})}
<Button
disabled={array.fields.length >= 100}
onClick={() =>
array.append({
id: UniqueID.generateNewID().toPrimitive(),
title: "",
instructions: "",
required: false,
state: "pending",
notes: "",
})
}
type="button"
variant="outline"
>
{t("interventions.addStep")}
</Button>
</section>
);
}

View File

@ -0,0 +1,275 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { Input } from "@repo/shadcn-ui/components/input";
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { type InterventionDTO, InterventionStateSchema } from "../../common/interventions";
import { interventionApi, interventionKeys } from "./intervention-api";
import { InterventionDetail } from "./intervention-detail";
import { InterventionForm } from "./intervention-form";
import { InterventionFeedback } from "./intervention-help";
export type InterventionFilters = {
search: string;
company: string;
state: string;
dateFrom: string;
dateTo: string;
assignee: string;
};
export function filterInterventions(
items: readonly InterventionDTO[],
filters: InterventionFilters
) {
const search = filters.search.trim().toLocaleLowerCase();
return items
.filter(
(item) =>
(!filters.company || item.companyId === filters.company) &&
(!filters.state || item.values.state === filters.state) &&
matchesPlannedDate(item.values.plannedDate, filters.dateFrom, filters.dateTo) &&
(!filters.assignee ||
(filters.assignee === "unassigned"
? item.values.assigneeId === null
: item.values.assigneeId === filters.assignee)) &&
(!search ||
[item.reference, item.values.subject, item.companyName, item.assigneeName ?? ""].some(
(text) => text.toLocaleLowerCase().includes(search)
))
)
.sort((a, b) =>
(b.history.at(-1)?.recordedAt ?? "").localeCompare(a.history.at(-1)?.recordedAt ?? "")
);
}
export function InterventionsPage({
companyId,
csrfToken,
}: {
companyId?: string;
csrfToken: string;
}) {
const { t, i18n } = useTranslation("companies");
const query = useQuery({
queryKey: interventionKeys.list(companyId),
queryFn: () => interventionApi.list(companyId),
});
const [filters, setFilters] = useState<InterventionFilters>({
search: "",
company: "",
state: "",
dateFrom: "",
dateTo: "",
assignee: "",
});
const [open, setOpen] = useState(false);
const [edit, setEdit] = useState<InterventionDTO | undefined>();
const [view, setView] = useState<InterventionDTO | undefined>();
const items = filterInterventions(query.data?.interventions ?? [], filters);
const date = (value: string | undefined) =>
value
? new Intl.DateTimeFormat(i18n.language, { dateStyle: "medium", timeStyle: "short" }).format(
new Date(value)
)
: "";
return (
<div className="space-y-5">
<header className="flex items-center justify-between gap-3">
<h2 className="text-xl font-semibold">
{t(companyId ? "interventions.title" : "interventions.global")}
</h2>
<Button
disabled={!query.data || query.isError}
onClick={() => {
setEdit(undefined);
setOpen(true);
}}
>
{t("interventions.new")}
</Button>
</header>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Input
aria-label={t("interventions.search")}
onChange={(event) => setFilters({ ...filters, search: event.target.value })}
placeholder={t("interventions.search")}
value={filters.search}
/>
{!companyId && (
<SelectWithAction
id="interventions-company-filter"
label={t("tickets.company")}
onValueChange={(company) => setFilters({ ...filters, company })}
options={
query.data?.companies.map((item) => ({ value: item.id, label: item.name })) ?? []
}
placeholder={t("interventions.allCompanies")}
value={filters.company}
/>
)}
<SelectWithAction
id="interventions-state-filter"
label={t("interventions.state")}
onValueChange={(state) => setFilters({ ...filters, state })}
options={InterventionStateSchema.options.map((value) => ({
value,
label: t(`interventions.states.${value}`),
}))}
placeholder={t("interventions.allStates")}
value={filters.state}
/>
<SelectWithAction
id="interventions-assignee-filter"
label={t("interventions.assignee")}
onValueChange={(assignee) => setFilters({ ...filters, assignee })}
options={[
{ value: "unassigned", label: t("interventions.unassigned") },
...(query.data?.technicians.map((item) => ({ value: item.id, label: item.name })) ??
[]),
]}
placeholder={t("interventions.allAssignees")}
value={filters.assignee}
/>
<Input
aria-label={t("interventions.dateFrom")}
onChange={(event) => setFilters({ ...filters, dateFrom: event.target.value })}
type="date"
value={filters.dateFrom}
/>
<Input
aria-label={t("interventions.dateTo")}
onChange={(event) => setFilters({ ...filters, dateTo: event.target.value })}
type="date"
value={filters.dateTo}
/>
<Button
onClick={() =>
setFilters({
search: "",
company: "",
state: "",
assignee: "",
dateFrom: "",
dateTo: "",
})
}
variant="outline"
>
{t("interventions.resetFilters")}
</Button>
</div>
{query.isPending && <p role="status">{t("interventions.loading")}</p>}
{query.isError && (
<div className="space-y-3">
<InterventionFeedback error={query.error} />
<Button
onClick={() => {
query.refetch().catch(() => undefined);
}}
variant="outline"
>
{t("retry")}
</Button>
</div>
)}
{query.data && !items.length && (
<p className="text-muted-foreground">{t("interventions.empty")}</p>
)}
<ul className="divide-y">
{items.map((intervention) => (
<li className="space-y-2 py-4" key={intervention.id}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-sm text-muted-foreground">
{intervention.reference} · {intervention.companyName}
</p>
<h3 className="font-semibold">{intervention.values.subject}</h3>
</div>
<div className="flex gap-2">
<Button onClick={() => setView(intervention)} size="sm" variant="outline">
{t("interventions.details")}
</Button>
<Button
onClick={() => {
setEdit(intervention);
setOpen(true);
}}
size="sm"
variant="outline"
>
{t("edit")}
</Button>
</div>
</div>
<p className="text-sm">
{t(`interventions.states.${intervention.values.state}`)} ·{" "}
{intervention.assigneeName ?? t("interventions.unassigned")} ·{" "}
{intervention.values.minutes === null
? t("interventions.unknownTime")
: `${intervention.values.minutes} min`}
</p>
<p className="text-sm text-muted-foreground">
{intervention.ticketReference ?? t("interventions.internal")}
{intervention.values.plannedDate ? ` · ${intervention.values.plannedDate}` : ""}
</p>
{intervention.values.steps.length > 0 && (
<p className="text-sm">
{t("interventions.progress", {
done: intervention.values.steps.filter((step) => step.state === "done").length,
total: intervention.values.steps.length,
})}
</p>
)}
<p className="text-xs text-muted-foreground">
{t("tickets.updated")}: {date(intervention.history.at(-1)?.recordedAt)}
</p>
</li>
))}
</ul>
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={setOpen}
open={open}
title={t(edit ? "interventions.edit" : "interventions.new")}
wide
>
{query.data && open && (
<InterventionForm
key={edit?.id ?? "new"}
{...(companyId ? { scope: companyId } : {})}
{...(edit ? { intervention: edit } : {})}
csrfToken={csrfToken}
data={query.data}
onSaved={() => setOpen(false)}
/>
)}
</FormDialog>
<FormDialog
cancelLabel={t("cancel")}
onOpenChange={(value) => {
if (!value) {
setView(undefined);
}
}}
open={Boolean(view)}
title={t("interventions.details")}
wide
>
{view && query.data && <InterventionDetail data={query.data} intervention={view} />}
</FormDialog>
</div>
);
}
function matchesPlannedDate(date: string | null, from: string, to: string) {
if (!(from || to)) {
return true;
}
if (!date) {
return false;
}
return (!from || date >= from) && (!to || date <= to);
}

View File

@ -0,0 +1,150 @@
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 type { InterventionDTO, InterventionListDTO } from "../../common/interventions";
import { companiesEnglish, companiesSpanish } from "../i18n";
import { interventionKeys } from "./intervention-api";
import { stepCompletion } from "./intervention-detail";
import { InterventionForm, emptyIntervention } from "./intervention-form";
import { InterventionsPage, filterInterventions } from "./interventions-page";
const companyId = UniqueID.generateNewID().toPrimitive();
const data: InterventionListDTO = {
interventions: [],
companies: [{ id: companyId, name: "Empresa test", active: true }],
technicians: [],
};
function item(): InterventionDTO {
const values = { ...emptyIntervention(), subject: "Atasco", plannedDate: "2026-10-07" };
return {
id: UniqueID.generateNewID().toPrimitive(),
companyId,
companyName: "Empresa test",
reference: "I-000001",
values,
assigneeName: null,
ticketReference: null,
procedure: null,
history: [
{
id: UniqueID.generateNewID().toPrimitive(),
values,
actorId: companyId,
actorName: "Técnico",
recordedAt: "2026-10-07T10:00:00.000Z",
},
],
};
}
async function render(node: ReactNode, language = "es") {
const i18n = createInstance();
await i18n.init({
lng: language,
resources: { es: { companies: companiesSpanish }, en: { companies: companiesEnglish } },
});
const client = new QueryClient();
client.setQueryData(interventionKeys.list(), data);
client.setQueryData(interventionKeys.list(companyId), data);
client.setQueryData(interventionKeys.lookups(companyId), {
people: [],
assets: [],
resources: [],
workstations: [],
tickets: [],
templates: [],
});
try {
return renderToStaticMarkup(
<I18nextProvider i18n={i18n}>
<QueryClientProvider client={client}>{node}</QueryClientProvider>
</I18nextProvider>
);
} finally {
client.clear();
}
}
test("intervenciones: filtros por compañía, técnico, estado y fecha prevista", () => {
const filters = { search: "", company: "", state: "", assignee: "", dateFrom: "", dateTo: "" };
const intervention = item();
assert.equal(filterInterventions([intervention], filters).length, 1);
assert.equal(filterInterventions([intervention], { ...filters, state: "finished" }).length, 0);
assert.equal(
filterInterventions([intervention], { ...filters, assignee: "unassigned" }).length,
1
);
assert.equal(
filterInterventions([intervention], { ...filters, dateFrom: "2026-10-08" }).length,
0
);
assert.equal(
filterInterventions([intervention], {
...filters,
dateFrom: "2026-10-07",
dateTo: "2026-10-07",
}).length,
1
);
assert.equal(filterInterventions([intervention], { ...filters, company: "other" }).length, 0);
});
test("intervenciones: vistas es/en sin formularios junto al listado", async () => {
const es = await render(<InterventionsPage csrfToken="test" />);
assert.ok(es.includes("Todas las intervenciones"));
assert.equal(es.includes("<form"), false);
const en = await render(<InterventionsPage csrfToken="test" />, "en");
assert.ok(en.includes("All interventions"));
});
test("intervenciones: modal sencillo, pasos opcionales, altas rápidas y ayuda inferior", async () => {
const html = await render(
<InterventionForm csrfToken="test" data={data} onSaved={() => undefined} scope={companyId} />
);
assert.ok(html.includes("Empresa test"));
assert.ok(html.includes("Nueva plantilla"));
assert.ok(html.includes("Nuevo ticket"));
assert.ok(html.includes("Añadir paso"));
assert.ok(html.indexOf("¿Para qué sirve una intervención?") < html.indexOf("Guardar"));
assert.equal(html.includes("Nombre del paso"), false);
});
test("intervenciones: autor de completar un paso se conserva al editar notas", () => {
const source = item();
const id = UniqueID.generateNewID().toPrimitive();
const step = {
id,
title: "Comprobar",
instructions: "",
required: false,
state: "done" as const,
notes: "",
};
const first = {
id: UniqueID.generateNewID().toPrimitive(),
actorId: companyId,
actorName: "Ana",
recordedAt: "2026-10-07T10:00:00.000Z",
values: { ...source.values, steps: [step] },
};
const second = {
...first,
id: UniqueID.generateNewID().toPrimitive(),
actorName: "David",
recordedAt: "2026-10-07T11:00:00.000Z",
values: { ...first.values, steps: [{ ...step, notes: "Nota" }] },
};
assert.equal(stepCompletion({ ...source, history: [first, second] }, id)?.actorName, "Ana");
const reopened = {
...second,
values: { ...second.values, steps: [{ ...step, state: "pending" as const }] },
};
assert.equal(stepCompletion({ ...source, history: [first, reopened] }, id), undefined);
assert.equal(
stepCompletion({ ...source, history: [first, reopened, second] }, id)?.actorName,
"David"
);
});

View File

@ -0,0 +1,81 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import type { TicketDTO } from "../../common/tickets";
import { interventionApi, interventionKeys } from "./intervention-api";
import { InterventionForm } from "./intervention-form";
import { InterventionFeedback } from "./intervention-help";
export function TicketInterventionForm({
ticket,
csrfToken,
onSaved,
}: {
ticket: TicketDTO;
csrfToken: string;
onSaved: () => void;
}) {
const { t } = useTranslation("companies");
const query = useQuery({
queryKey: interventionKeys.list(ticket.companyId),
queryFn: () => interventionApi.list(ticket.companyId),
});
return (
<>
{query.isPending && <p role="status">{t("interventions.loading")}</p>}
<InterventionFeedback error={query.error} />
{query.isError && (
<Button
onClick={() => {
query.refetch().catch(() => undefined);
}}
type="button"
variant="outline"
>
{t("retry")}
</Button>
)}
{query.data && !query.isError && (
<InterventionForm
csrfToken={csrfToken}
data={query.data}
initialTicket={ticket}
onSaved={onSaved}
/>
)}
</>
);
}
export function TicketInterventions({
companyId,
ticketId,
}: {
companyId: string;
ticketId: string;
}) {
const { t } = useTranslation("companies");
const query = useQuery({
queryKey: interventionKeys.list(companyId),
queryFn: () => interventionApi.list(companyId),
});
const items = query.data?.interventions.filter((item) => item.values.ticketId === ticketId) ?? [];
return (
<section className="space-y-2">
<h4 className="font-medium">{t("interventions.title")}</h4>
{query.isPending && <p role="status">{t("interventions.loading")}</p>}
<InterventionFeedback error={query.error} />
{query.data && !query.isError && (
<ul className="space-y-2 text-sm">
{items.map((item) => (
<li key={item.id}>
{item.reference} · {item.values.subject} ·{" "}
{t(`interventions.states.${item.values.state}`)} ·{" "}
{item.assigneeName ?? t("interventions.unassigned")}
</li>
))}
</ul>
)}
</section>
);
}

View File

@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import type { TicketDTO, TicketListDTO } from "../../common/tickets";
import { TicketInterventions } from "../interventions/ticket-interventions";
import { ticketApi, ticketKeys } from "./ticket-api";
import { ticketTargetOptions } from "./ticket-targets";
@ -50,6 +51,7 @@ export function TicketDetail({ ticket, data }: { ticket: TicketDTO; data: Ticket
{ticket.values.description && (
<p className="whitespace-pre-wrap break-words">{ticket.values.description}</p>
)}
<TicketInterventions companyId={ticket.companyId} ticketId={ticket.id} />
<h4 className="font-medium">
{t("tickets.history")} ({ticket.history.length})
</h4>

View File

@ -35,7 +35,7 @@ export function TicketForm({
ticket?: TicketDTO;
data: TicketListDTO;
csrfToken: string;
onSaved: () => void;
onSaved: (id: string) => void;
}) {
const { t } = useTranslation("companies");
const prefix = useId();
@ -70,9 +70,9 @@ export function TicketForm({
csrfToken,
ticket?.id
),
onSuccess: async () => {
onSuccess: async (response) => {
await client.invalidateQueries({ queryKey: ["tickets"] });
onSaved();
onSaved(response.ticket.id);
},
});
const changeCompany = (id: string) => {

View File

@ -28,12 +28,14 @@ export function TicketTargets({
values,
lookups,
onChange,
lookupKey,
}: {
companyId: string;
csrfToken: string;
values: TicketTargetInput[];
lookups: TicketLookupsDTO;
onChange: (values: TicketTargetInput[]) => void;
lookupKey?: readonly unknown[];
}) {
const { t } = useTranslation("companies");
const prefix = useId();
@ -141,7 +143,7 @@ export function TicketTargets({
kind={quick}
onSaved={(id) => {
client
.invalidateQueries({ queryKey: ticketKeys.lookups(companyId) })
.invalidateQueries({ queryKey: lookupKey ?? ticketKeys.lookups(companyId) })
.then(() => {
add(id, quick);
setQuick(undefined);

View File

@ -7,6 +7,7 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { type TicketDTO, TicketPrioritySchema, TicketStateSchema } from "../../common/tickets";
import { TicketInterventionForm } from "../interventions/ticket-interventions";
import { ticketApi, ticketKeys } from "./ticket-api";
import { TicketDetail } from "./ticket-detail";
@ -56,6 +57,7 @@ export function TicketsPage({ companyId, csrfToken }: { companyId?: string; csrf
const [open, setOpen] = useState(false);
const [edit, setEdit] = useState<TicketDTO | undefined>();
const [view, setView] = useState<TicketDTO | undefined>();
const [interventionTicket, setInterventionTicket] = useState<TicketDTO>();
const items = filterTickets(query.data?.tickets ?? [], filters);
const date = (value: string | undefined) =>
value
@ -159,6 +161,9 @@ export function TicketsPage({ companyId, csrfToken }: { companyId?: string; csrf
<h3 className="font-semibold">{ticket.values.subject}</h3>
</div>
<div className="flex gap-2">
<Button onClick={() => setInterventionTicket(ticket)} size="sm" variant="outline">
{t("interventions.fromTicket")}
</Button>
<Button onClick={() => setView(ticket)} size="sm" variant="outline">
{t("tickets.details")}
</Button>
@ -205,6 +210,26 @@ export function TicketsPage({ companyId, csrfToken }: { companyId?: string; csrf
/>
)}
</FormDialog>
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(value) => {
if (!value) {
setInterventionTicket(undefined);
}
}}
open={Boolean(interventionTicket)}
title={t("interventions.new")}
wide
>
{interventionTicket && (
<TicketInterventionForm
csrfToken={csrfToken}
onSaved={() => setInterventionTicket(undefined)}
ticket={interventionTicket}
/>
)}
</FormDialog>
<FormDialog
cancelLabel={t("cancel")}
onOpenChange={(value) => {