diff --git a/apps/server/src/identity.test.ts b/apps/server/src/identity.test.ts index ac9602d..1afa2d5 100644 --- a/apps/server/src/identity.test.ts +++ b/apps/server/src/identity.test.ts @@ -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"); diff --git a/apps/server/src/intervention-templates.integration.ts b/apps/server/src/intervention-templates.integration.ts new file mode 100644 index 0000000..455a23e --- /dev/null +++ b/apps/server/src/intervention-templates.integration.ts @@ -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; +export async function verifyInterventionTemplates( + request: Request, + companyId: string, + otherId: string, + migrate: () => Promise +) { + 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); +} diff --git a/apps/server/src/interventions.integration.ts b/apps/server/src/interventions.integration.ts new file mode 100644 index 0000000..b4f4af4 --- /dev/null +++ b/apps/server/src/interventions.integration.ts @@ -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; +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); +} diff --git a/docs/arquitectura/modelo-ddd-e-identificadores.md b/docs/arquitectura/modelo-ddd-e-identificadores.md index 668021b..fe84caf 100644 --- a/docs/arquitectura/modelo-ddd-e-identificadores.md +++ b/docs/arquitectura/modelo-ddd-e-identificadores.md @@ -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. diff --git a/docs/intervenciones-y-plantillas.md b/docs/intervenciones-y-plantillas.md new file mode 100644 index 0000000..cd54143 --- /dev/null +++ b/docs/intervenciones-y-plantillas.md @@ -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. diff --git a/docs/plan-de-desarrollo.md b/docs/plan-de-desarrollo.md index fc2bcc4..6e4625d 100644 --- a/docs/plan-de-desarrollo.md +++ b/docs/plan-de-desarrollo.md @@ -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. diff --git a/docs/tickets.md b/docs/tickets.md index a20c697..6d008f2 100644 --- a/docs/tickets.md +++ b/docs/tickets.md @@ -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). diff --git a/modules/companies/package.json b/modules/companies/package.json index 5986792..4ebf1ff 100644 --- a/modules/companies/package.json +++ b/modules/companies/package.json @@ -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:*", diff --git a/modules/companies/src/api/application/intervention-input.mapper.ts b/modules/companies/src/api/application/intervention-input.mapper.ts new file mode 100644 index 0000000..03691e8 --- /dev/null +++ b/modules/companies/src/api/application/intervention-input.mapper.ts @@ -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; +} diff --git a/modules/companies/src/api/application/intervention-response.builder.ts b/modules/companies/src/api/application/intervention-response.builder.ts new file mode 100644 index 0000000..3cc33db --- /dev/null +++ b/modules/companies/src/api/application/intervention-response.builder.ts @@ -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)), + }; +} diff --git a/modules/companies/src/api/application/intervention-template-response.builder.ts b/modules/companies/src/api/application/intervention-template-response.builder.ts new file mode 100644 index 0000000..f19ea0e --- /dev/null +++ b/modules/companies/src/api/application/intervention-template-response.builder.ts @@ -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 +): 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, + }; + }), + }; +} diff --git a/modules/companies/src/api/application/intervention-template.repository.ts b/modules/companies/src/api/application/intervention-template.repository.ts new file mode 100644 index 0000000..243ca61 --- /dev/null +++ b/modules/companies/src/api/application/intervention-template.repository.ts @@ -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; +}; +export interface IInterventionTemplateRepository { + list( + companyId: UniqueID | undefined + ): Promise>; + save( + companyId: UniqueID | undefined, + action: ( + templates: InterventionTemplate[] + ) => Result + ): Promise< + Result< + InterventionTemplateContext & { template: InterventionTemplate }, + InterventionTemplateError + > + >; +} diff --git a/modules/companies/src/api/application/intervention-template.use-cases.ts b/modules/companies/src/api/application/intervention-template.use-cases.ts new file mode 100644 index 0000000..4529c80 --- /dev/null +++ b/modules/companies/src/api/application/intervention-template.use-cases.ts @@ -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 { + 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 + ); +} diff --git a/modules/companies/src/api/application/intervention.repository.ts b/modules/companies/src/api/application/intervention.repository.ts new file mode 100644 index 0000000..da78da3 --- /dev/null +++ b/modules/companies/src/api/application/intervention.repository.ts @@ -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; +}; +export type InterventionSaveContext = InterventionContext & + InterventionLookups & { nextNumber: number; requestIntervention: Intervention | undefined }; +export interface IInterventionRepository { + list(company?: UniqueID): Promise>; + lookups(company: UniqueID): Promise>; + save( + company: UniqueID, + requestKey: UniqueID, + action: (context: InterventionSaveContext) => Result + ): Promise< + Result<{ intervention: Intervention; context: InterventionContext }, InterventionError> + >; +} diff --git a/modules/companies/src/api/application/intervention.use-cases.ts b/modules/companies/src/api/application/intervention.use-cases.ts new file mode 100644 index 0000000..03a6480 --- /dev/null +++ b/modules/companies/src/api/application/intervention.use-cases.ts @@ -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 { + 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 +): Result { + 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, 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); +} diff --git a/modules/companies/src/api/domain/intervention-revision.entity.ts b/modules/companies/src/api/domain/intervention-revision.entity.ts new file mode 100644 index 0000000..8e5bb62 --- /dev/null +++ b/modules/companies/src/api/domain/intervention-revision.entity.ts @@ -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 { + 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; + } +} diff --git a/modules/companies/src/api/domain/intervention-step.entity.ts b/modules/companies/src/api/domain/intervention-step.entity.ts new file mode 100644 index 0000000..51fac39 --- /dev/null +++ b/modules/companies/src/api/domain/intervention-step.entity.ts @@ -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 { + private constructor(props: Props, id: UniqueID) { + super(Object.freeze({ ...props }), id); + } + static create(input: InterventionCheckInput): Result { + 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 + ); + } +} diff --git a/modules/companies/src/api/domain/intervention-template-revision.entity.ts b/modules/companies/src/api/domain/intervention-template-revision.entity.ts new file mode 100644 index 0000000..ec5a13f --- /dev/null +++ b/modules/companies/src/api/domain/intervention-template-revision.entity.ts @@ -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; + recordedAt: string; +}>; +export class InterventionTemplateRevision extends DomainEntity { + 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; + } +} diff --git a/modules/companies/src/api/domain/intervention-template-seed.ts b/modules/companies/src/api/domain/intervention-template-seed.ts new file mode 100644 index 0000000..e64a6e0 --- /dev/null +++ b/modules/companies/src/api/domain/intervention-template-seed.ts @@ -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; + }); +} diff --git a/modules/companies/src/api/domain/intervention-template-step.entity.ts b/modules/companies/src/api/domain/intervention-template-step.entity.ts new file mode 100644 index 0000000..67c240e --- /dev/null +++ b/modules/companies/src/api/domain/intervention-template-step.entity.ts @@ -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 { + private constructor(props: Props, id: UniqueID) { + super(Object.freeze({ ...props }), id); + } + static create( + input: InterventionStepInput + ): Result { + 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, + }; + } +} diff --git a/modules/companies/src/api/domain/intervention-template-values.ts b/modules/companies/src/api/domain/intervention-template-values.ts new file mode 100644 index 0000000..1580e2c --- /dev/null +++ b/modules/companies/src/api/domain/intervention-template-values.ts @@ -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; +export type InterventionTemplateInput = z.infer; diff --git a/modules/companies/src/api/domain/intervention-template.aggregate.ts b/modules/companies/src/api/domain/intervention-template.aggregate.ts new file mode 100644 index 0000000..48e1683 --- /dev/null +++ b/modules/companies/src/api/domain/intervention-template.aggregate.ts @@ -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; + requestKey: UniqueID; + values: InterventionTemplateValues; + history: readonly InterventionTemplateRevision[]; +}>; +export class InterventionTemplate extends AggregateRoot { + private constructor(props: Props, id?: UniqueID) { + super(Object.freeze({ ...props, history: Object.freeze([...props.history]) }), id); + } + static create( + companyId: Maybe, + requestKey: UniqueID, + values: InterventionTemplateValues, + actorId: Maybe, + recordedAt: string + ): Result { + 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 { + 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 + ) + ); + } +} diff --git a/modules/companies/src/api/domain/intervention-template.test.ts b/modules/companies/src/api/domain/intervention-template.test.ts new file mode 100644 index 0000000..fae2a9a --- /dev/null +++ b/modules/companies/src/api/domain/intervention-template.test.ts @@ -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 + ) + ); +}); diff --git a/modules/companies/src/api/domain/intervention-values.ts b/modules/companies/src/api/domain/intervention-values.ts new file mode 100644 index 0000000..5fce95c --- /dev/null +++ b/modules/companies/src/api/domain/intervention-values.ts @@ -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; +export type InterventionCheckInput = z.infer; +export class InterventionError extends Error { + constructor(public readonly code: string) { + super(code); + } +} diff --git a/modules/companies/src/api/domain/intervention.aggregate.ts b/modules/companies/src/api/domain/intervention.aggregate.ts new file mode 100644 index 0000000..b5ba4ff --- /dev/null +++ b/modules/companies/src/api/domain/intervention.aggregate.ts @@ -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; + reference: Name; + requestKey: UniqueID; + values: InterventionValues; + history: readonly InterventionRevision[]; +}>; +export class Intervention extends AggregateRoot { + 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, + actorId: UniqueID, + recordedAt: string + ): Result { + 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 { + 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 + ) + ); + } +} diff --git a/modules/companies/src/api/domain/intervention.test.ts b/modules/companies/src/api/domain/intervention.test.ts new file mode 100644 index 0000000..093c3da --- /dev/null +++ b/modules/companies/src/api/domain/intervention.test.ts @@ -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() + ); +}); diff --git a/modules/companies/src/api/domain/value-objects/intervention-procedure.ts b/modules/companies/src/api/domain/value-objects/intervention-procedure.ts new file mode 100644 index 0000000..b31329f --- /dev/null +++ b/modules/companies/src/api/domain/value-objects/intervention-procedure.ts @@ -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 { + 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(), + }; + } +} diff --git a/modules/companies/src/api/domain/value-objects/intervention-template-values.ts b/modules/companies/src/api/domain/value-objects/intervention-template-values.ts new file mode 100644 index 0000000..30d6dfe --- /dev/null +++ b/modules/companies/src/api/domain/value-objects/intervention-template-values.ts @@ -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 { + 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()), + }; + } +} diff --git a/modules/companies/src/api/domain/value-objects/intervention-values.ts b/modules/companies/src/api/domain/value-objects/intervention-values.ts new file mode 100644 index 0000000..e792c0f --- /dev/null +++ b/modules/companies/src/api/domain/value-objects/intervention-values.ts @@ -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; +const keys: readonly (keyof Data)[] = [ + "state", + "modality", + "plannedDate", + "startedAt", + "endedAt", + "minutes", + "diagnosis", + "workPerformed", + "resolution", +]; +function optionalId(input: string | null): Result, 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, + public readonly assigneeId: Maybe, + 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 { + 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); + }) + ); + } +} diff --git a/modules/companies/src/api/infrastructure/http/intervention-template.routes.ts b/modules/companies/src/api/infrastructure/http/intervention-template.routes.ts new file mode 100644 index 0000000..244b080 --- /dev/null +++ b/modules/companies/src/api/infrastructure/http/intervention-template.routes.ts @@ -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); +} diff --git a/modules/companies/src/api/infrastructure/http/intervention.routes.ts b/modules/companies/src/api/infrastructure/http/intervention.routes.ts new file mode 100644 index 0000000..c5208c8 --- /dev/null +++ b/modules/companies/src/api/infrastructure/http/intervention.routes.ts @@ -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); +} diff --git a/modules/companies/src/api/infrastructure/persistence/intervention-migration.ts b/modules/companies/src/api/infrastructure/persistence/intervention-migration.ts new file mode 100644 index 0000000..9643d56 --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/intervention-migration.ts @@ -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, + }); + } +} diff --git a/modules/companies/src/api/infrastructure/persistence/intervention-persistence.mapper.ts b/modules/companies/src/api/infrastructure/persistence/intervention-persistence.mapper.ts new file mode 100644 index 0000000..39b84be --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/intervention-persistence.mapper.ts @@ -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, rows: readonly Record[]) { + 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 | null +): Maybe { + 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); +} diff --git a/modules/companies/src/api/infrastructure/persistence/intervention-template-migration.ts b/modules/companies/src/api/infrastructure/persistence/intervention-template-migration.ts new file mode 100644 index 0000000..92b2a0a --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/intervention-template-migration.ts @@ -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 } + ); + } + } + }); +} diff --git a/modules/companies/src/api/infrastructure/persistence/intervention-template-persistence.mapper.ts b/modules/companies/src/api/infrastructure/persistence/intervention-template-persistence.mapper.ts new file mode 100644 index 0000000..ae08fb2 --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/intervention-template-persistence.mapper.ts @@ -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) { + 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, + })), + }; + } +} diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize-intervention-template.repository.ts b/modules/companies/src/api/infrastructure/persistence/sequelize-intervention-template.repository.ts new file mode 100644 index 0000000..42abb62 --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/sequelize-intervention-template.repository.ts @@ -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 + ) { + 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 } + ); + } + } +} diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize-intervention.repository.ts b/modules/companies/src/api/infrastructure/persistence/sequelize-intervention.repository.ts new file mode 100644 index 0000000..cab0b78 --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/sequelize-intervention.repository.ts @@ -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> { + const context = await this.tickets.list(company); + if (!context.isSuccess) { + return Result.fail(new InterventionError(context.error.code)); + } + const roots = await this.database.query>( + "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>( + "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> { + 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 + ) { + 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 } + ); + } + } +} diff --git a/modules/companies/src/api/module.ts b/modules/companies/src/api/module.ts index e834d51..f6afe81 100644 --- a/modules/companies/src/api/module.ts +++ b/modules/companies/src/api/module.ts @@ -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[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[0]) => { + registerInterventionTemplateRoutes(app, interventionTemplates, authenticate); registerCompanyRoutes(app, useCases, authenticate); registerTeleworkAuthorizationRoutes(app, telework, authenticate); registerAccessRoutes(app, access, authenticate, legacyResourceWrites); diff --git a/modules/companies/src/common/index.ts b/modules/companies/src/common/index.ts index 936c8a6..0bfe07f 100644 --- a/modules/companies/src/common/index.ts +++ b/modules/companies/src/common/index.ts @@ -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"; diff --git a/modules/companies/src/common/intervention-templates.ts b/modules/companies/src/common/intervention-templates.ts new file mode 100644 index 0000000..336e53b --- /dev/null +++ b/modules/companies/src/common/intervention-templates.ts @@ -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; +export type SaveInterventionTemplateInput = z.infer; diff --git a/modules/companies/src/common/interventions.ts b/modules/companies/src/common/interventions.ts new file mode 100644 index 0000000..edfa7d7 --- /dev/null +++ b/modules/companies/src/common/interventions.ts @@ -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; +export type InterventionListDTO = z.infer; +export type InterventionLookupsDTO = z.infer; +export type SaveInterventionInput = z.infer; diff --git a/modules/companies/src/web/components/companies-workspace.tsx b/modules/companies/src/web/components/companies-workspace.tsx index 238aa4c..8aa1c2d 100644 --- a/modules/companies/src/web/components/companies-workspace.tsx +++ b/modules/companies/src/web/components/companies-workspace.tsx @@ -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(); - 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 +
{companies.map((company) => ( + )} @@ -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 ; case "access": return ; 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 ; case "tickets": return ; case "directory": diff --git a/modules/companies/src/web/components/company-detail.tsx b/modules/companies/src/web/components/company-detail.tsx index 73c155f..c48fc9a 100644 --- a/modules/companies/src/web/components/company-detail.tsx +++ b/modules/companies/src/web/components/company-detail.tsx @@ -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 ; + case "interventions": + return ; case "tickets": return ; case "processes": diff --git a/modules/companies/src/web/i18n.ts b/modules/companies/src/web/i18n.ts index 8458668..419bdad 100644 --- a/modules/companies/src/web/i18n.ts +++ b/modules/companies/src/web/i18n.ts @@ -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, diff --git a/modules/companies/src/web/intervention-templates/template-api.ts b/modules/companies/src/web/intervention-templates/template-api.ts new file mode 100644 index 0000000..2ab5756 --- /dev/null +++ b/modules/companies/src/web/intervention-templates/template-api.ts @@ -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)); + }, +}; diff --git a/modules/companies/src/web/intervention-templates/template-detail.tsx b/modules/companies/src/web/intervention-templates/template-detail.tsx new file mode 100644 index 0000000..3a04568 --- /dev/null +++ b/modules/companies/src/web/intervention-templates/template-detail.tsx @@ -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 ( + <> +

{values.description}

+
    + {values.steps.map((step) => ( +
  1. + {step.title} + {step.required && ( + + · {t("interventionTemplates.required")} + + )} + {step.instructions && ( +

    {step.instructions}

    + )} +
  2. + ))} +
+ + ); +} +export function TemplateHistory({ template }: { template: InterventionTemplateDTO }) { + const { t, i18n } = useTranslation("companies"); + return ( +
+ {t("history")} +
    + {[...template.history].reverse().map((revision) => ( +
  1. +

    + {new Date(revision.recordedAt).toLocaleString(i18n.language)} · {revision.actorName} +

    +

    + {revision.values.name} · {t(revision.values.active ? "active" : "inactive")} +

    + +
  2. + ))} +
+
+ ); +} diff --git a/modules/companies/src/web/intervention-templates/template-form.tsx b/modules/companies/src/web/intervention-templates/template-form.tsx new file mode 100644 index 0000000..59aad6a --- /dev/null +++ b/modules/companies/src/web/intervention-templates/template-form.tsx @@ -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({ + 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 ( +
mutation.mutate(values))}> +
+
+ + +
+
+ +