diff --git a/apps/server/src/identity.test.ts b/apps/server/src/identity.test.ts index 90d0341..ac9602d 100644 --- a/apps/server/src/identity.test.ts +++ b/apps/server/src/identity.test.ts @@ -36,6 +36,7 @@ import { verifyResourceMigration } from "./resource-migration.integration"; import { verifyResources } from "./resources.integration"; import { verifySupport } from "./support.integration"; import { verifyTelework } from "./telework.integration"; +import { verifyTickets } from "./tickets.integration"; import { verifyWorkProfiles } from "./work-profiles.integration"; import { verifyWorkstations } from "./workstations.integration"; @@ -52,6 +53,9 @@ 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 ticket_revisions"); + await database.query("DELETE FROM tickets"); + await database.query("UPDATE ticket_counter SET nextNumber=1 WHERE id=1"); await database.query("DELETE FROM mail_configuration_revisions"); await database.query("DELETE FROM mail_configurations"); await database.query("DELETE FROM mailbox_use_revisions"); @@ -818,6 +822,7 @@ 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 verifyTickets((...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"); @@ -914,6 +919,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de 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"); await assert.rejects( diff --git a/apps/server/src/tickets.integration.ts b/apps/server/src/tickets.integration.ts new file mode 100644 index 0000000..0f5304e --- /dev/null +++ b/apps/server/src/tickets.integration.ts @@ -0,0 +1,175 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; + +import { InventoryResponseSchema } from "@metis/assets/common"; +import { + CompanyResponseSchema, + type TicketDTO, + type TicketInput, + TicketListSchema, + TicketLookupsSchema, + TicketResponseSchema, +} from "@metis/companies/common"; +import { z } from "zod/v4"; + +type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise; +export async function verifyTickets(request: Request, companyId: string, otherId: string) { + const path = `/tickets/${companyId}`; + const key = randomUUID(); + const lookupsResponse = await request(`${path}/lookups`); + assert.equal(lookupsResponse.status, 200); + const lookups = TicketLookupsSchema.parse(await lookupsResponse.json()); + const list = TicketListSchema.parse(await (await request("/tickets")).json()); + const person = lookups.people.find((item) => item.active); + const asset = lookups.assets.find((item) => item.active); + const technician = list.technicians.find((item) => item.active); + assert.ok(person && asset && technician); + const values: TicketInput = { + subject: "Ticket de prueba", + description: "Correo no recibe mensajes", + requesterId: person.id, + assigneeId: technician.id, + state: "open", + priority: "normal", + channel: "phone", + affected: [ + { kind: "asset", targetId: asset.id }, + { kind: "person", targetId: person.id }, + ], + }; + const body = { values, requestKey: key, expectedRevisionId: null }; + 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 createdResponse = await request(path, body); + assert.equal(createdResponse.status, 201, await createdResponse.clone().text()); + const created = TicketResponseSchema.parse(await createdResponse.json()).ticket; + assert.equal(created.id[14], "7"); + assert.match(created.reference, /^T-[0-9]{6,}$/); + assert.equal(created.history.length, 1); + assert.equal(created.requesterName, person.name); + assert.equal(created.assigneeName, technician.name); + const repeatedResponse = await request(path, body); + assert.equal(repeatedResponse.status, 201); + assert.equal(TicketResponseSchema.parse(await repeatedResponse.json()).ticket.id, created.id); + await error(path, { ...body, values: { ...values, subject: "Otro" } }, 409, "REQUEST_KEY_REUSED"); + await error(`/tickets/${otherId}`, body, 409, "REQUEST_KEY_REUSED"); + const otherPerson = TicketLookupsSchema.parse( + await (await request(`/tickets/${otherId}/lookups`)).json() + ).people[0]; + if (otherPerson) { + await error( + path, + { ...body, requestKey: randomUUID(), values: { ...values, requesterId: otherPerson.id } }, + 409, + "REQUESTER_UNAVAILABLE" + ); + } + await error( + `/tickets/${otherId}/${created.id}/update`, + { ...body, expectedRevisionId: created.history[0]?.id }, + 404, + "TICKET_NOT_FOUND" + ); + await error( + path, + { + ...body, + requestKey: randomUUID(), + values: { ...values, affected: [{ kind: "resource", targetId: asset.id }] }, + }, + 409, + "TARGET_UNAVAILABLE" + ); + await error( + path, + { ...body, requestKey: randomUUID(), values: { ...values, assigneeId: randomUUID() } }, + 409, + "ASSIGNEE_UNAVAILABLE" + ); + await error(path, { ...body, values: { ...values, subject: "" } }, 400, "INVALID_REQUEST"); + assert.equal((await request(path, body, "http://evil.example")).status, 403); + assert.equal((await request(path, body, undefined, "invalid")).status, 403); + assert.equal((await request("/tickets?unknown=1")).status, 400); + async function update(ticket: TicketDTO, next: TicketInput) { + const response = await request(`${path}/${ticket.id}/update`, { + values: next, + requestKey: randomUUID(), + expectedRevisionId: ticket.history.at(-1)?.id, + }); + assert.equal(response.status, 200, await response.clone().text()); + return TicketResponseSchema.parse(await response.json()).ticket; + } + const same = await update(created, values); + assert.equal(same.history.length, 1); + const updated = await update(same, { ...values, state: "in_progress", priority: "high" }); + assert.equal(updated.history.length, 2); + assert.equal(updated.reference, created.reference); + assert.equal(updated.history[0]?.values.state, "open"); + await error( + `${path}/${created.id}/update`, + { ...body, expectedRevisionId: created.history[0]?.id }, + 409, + "TICKET_CHANGED" + ); + const concurrent = await Promise.all( + ["resolved", "waiting"].map((state) => + request(`${path}/${created.id}/update`, { + ...body, + requestKey: randomUUID(), + expectedRevisionId: updated.history.at(-1)?.id, + values: { ...updated.values, state }, + }) + ) + ); + assert.deepEqual(concurrent.map((response) => response.status).sort(), [200, 409]); + const other = await request(`/tickets/${otherId}`, { + values: { ...values, requesterId: null, assigneeId: null, affected: [] }, + requestKey: randomUUID(), + expectedRevisionId: null, + }); + assert.equal(other.status, 201); + const otherTicket = TicketResponseSchema.parse(await other.json()).ticket; + assert.notEqual(otherTicket.reference, created.reference); + const global = TicketListSchema.parse(await (await request("/tickets")).json()); + assert.ok(global.tickets.some((item) => item.id === created.id)); + assert.ok(global.tickets.some((item) => item.id === otherTicket.id)); + const scoped = TicketListSchema.parse(await (await request(path)).json()); + assert.ok(scoped.tickets.every((item) => item.companyId === companyId)); + const originalPerson = CompanyResponseSchema.parse( + await (await request(`/companies/${companyId}`)).json() + ).company.people.find((item) => item.id === person.id); + assert.ok(originalPerson); + const personValues = { + name: originalPerson.name, + position: originalPerson.position, + email: originalPerson.email, + phone: originalPerson.phone, + active: originalPerson.active, + isContact: originalPerson.isContact, + }; + const disabledPerson = await request(`/companies/${companyId}/people/${person.id}/update`, { + ...personValues, + active: false, + }); + assert.equal(disabledPerson.status, 200); + CompanyResponseSchema.parse(await disabledPerson.json()); + const current = scoped.tickets.find((item) => item.id === created.id); + assert.ok(current); + const retained = await update(current, { + ...current.values, + description: "Referencia histórica retenida", + }); + assert.equal(retained.values.requesterId, person.id); + await error(path, { ...body, requestKey: randomUUID() }, 409, "REQUESTER_UNAVAILABLE"); + assert.equal( + (await request(`/companies/${companyId}/people/${person.id}/update`, personValues)).status, + 200 + ); + const host = InventoryResponseSchema.parse( + await (await request(`/assets/${companyId}`)).json() + ).assets.find((item) => item.id === asset.id); + assert.ok(host); +} diff --git a/docs/arquitectura/modelo-ddd-e-identificadores.md b/docs/arquitectura/modelo-ddd-e-identificadores.md index 15cc338..668021b 100644 --- a/docs/arquitectura/modelo-ddd-e-identificadores.md +++ b/docs/arquitectura/modelo-ddd-e-identificadores.md @@ -90,3 +90,7 @@ ProcessTemplate y PersonProcess son AggregateRoot; sus revisiones y las tareas P ## Asistencia técnica remota SupportConnection delimita cada método de asistencia de un activo de una compañía. SupportValues encapsula la referencia UniqueID a herramienta y los valores de configuración; SupportConnectionRevision conserva las instantáneas con actor y fecha. SupportTool es la raíz del catálogo global y utiliza Name. Todas las raíces y revisiones nuevas tienen UUID v7. Consultar [Asistencia técnica remota](../asistencia-tecnica-remota.md). + +## 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). diff --git a/docs/plan-de-desarrollo.md b/docs/plan-de-desarrollo.md index 4de384b..fc2bcc4 100644 --- a/docs/plan-de-desarrollo.md +++ b/docs/plan-de-desarrollo.md @@ -85,7 +85,7 @@ Varios métodos por activo, catálogo global ampliable con herramientas de serie ## Siguientes entregas: orden actualizado -1. Tickets e intervenciones: concretar las peticiones recibidas por email/teléfono, solicitante y elementos afectados; múltiples intervenciones por ticket e intervenciones internas sin ticket. +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. Este orden se concreta antes de cada entrega. Las licencias y su relación con cuentas se explorarán posteriormente. @@ -96,10 +96,13 @@ Activos por compañía, plantilla capturada, sede y persona opcionales, cuatro e Las [relaciones entre activos](relaciones-entre-activos.md) están implementadas: catálogo global y local, cinco tipos de serie, vínculos en ambos sentidos y retirada con auditoría. Los [puestos de trabajo](puestos-de-trabajo.md) están implementados, sin anidación, con sede y persona opcionales, composición exclusiva, recursos compartidos entre puestos vigentes e historial. -## Tickets e intervenciones: pendiente +## Tickets: implementados -Cliente, sede, activo afectado, técnico, fecha, tiempo empleado, diagnóstico, trabajo realizado y resolución. -Un ticket puede tener varias intervenciones. No confundir la solicitud de soporte con cada trabajo realizado. +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 + +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. ## Servicios y documentación: pendiente @@ -108,4 +111,4 @@ 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 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 siguen pendientes. diff --git a/docs/tickets.md b/docs/tickets.md new file mode 100644 index 0000000..a20c697 --- /dev/null +++ b/docs/tickets.md @@ -0,0 +1,48 @@ +# Tickets + +Cada ticket pertenece a una única compañía, que no puede cambiar tras el alta. Tiene UUID v7 y referencia corta global T-000001. La referencia se reserva en una transacción; no sustituye al UUID. + +## Información y comportamiento + +Asunto obligatorio y descripción opcional. Solicitante opcional entre las personas de la compañía; responsable opcional entre los usuarios internos de Metis Desk. Las personas de las compañías no acceden a esta aplicación. + +Canales: email, teléfono, interno y otro. Estados: abierto, en curso, en espera, resuelto y cancelado. Prioridades: baja, normal, alta y urgente. El formulario empieza abierto, prioridad normal y canal email. Se puede reabrir un ticket editando su estado; siempre se conserva el historial. + +Elementos afectados opcionales y múltiples: personas, puestos de trabajo, activos y recursos de la misma compañía. No se repite el mismo tipo e identificador. Solo se añaden elementos disponibles y personas/usuarios activos; las referencias existentes que se desactivan se pueden conservar para consulta e historial. + +Cada cambio semántico crea TicketRevision con UUID, usuario y fecha. Guardar sin cambios no añade otra revisión. El último UUID de revisión protege la edición frente a cambios simultáneos: el formulario muestra un conflicto y permite cerrarlo y abrir la versión actual. + +Las altas incluyen una clave de solicitud para evitar duplicar un ticket al reintentar. La misma clave y datos originales devuelve el ticket existente; reutilizarla con otros datos, actor o compañía se rechaza. El navegador genera la clave con el generador compartido, compatible con HTTP por IP local. + +No se eliminan tickets. Cancelar o resolver conserva toda la información. Esta entrega no incluye intervenciones ni tiempos de trabajo. + +## Interfaz + +Tickets dentro de cada compañía muestra únicamente sus registros. Todos los tickets se abre desde el acceso T de la barra de espacios; reúne todas las compañías. Permite buscar por referencia/asunto/compañía/solicitante y filtrar por compañía (vista global), estado, prioridad y responsable, incluyendo sin asignar. Orden inicial: último cambio más reciente. + +Nuevo ticket está a la derecha de la cabecera. Alta y edición en modal; la ayuda aparece inmediatamente antes del pie Guardar/Cancelar. En la compañía, esta queda fijada. En la vista global se selecciona; cambiarla antes de guardar limpia solicitante y elementos afectados, conservando el resto del formulario. + +SelectWithAction permite crear compañía, solicitante y elementos afectados desde el propio formulario. Las altas rápidas reutilizan los formularios de persona, activo, recurso y puesto, conservan los datos del ticket y seleccionan el elemento creado. + +Ficha e historial muestra datos, revisiones, autor y fecha. Los nombres de referencias se consultan en los catálogos actuales; las revisiones conservan los identificadores originales. + +## Arquitectura y persistencia + +Ticket es AggregateRoot, TicketValues encapsula Name, referencias UniqueID/Maybe y TicketTarget como valor de referencia tipada. TicketRevision es DomainEntity. Las referencias afectadas no tienen un ciclo de vida independiente; se comparan como valores sin importar el orden. + +Migración explícita e idempotente: tickets, ticket_revisions y ticket_counter (contador técnico). FK a compañía, solicitantes, responsables y actores, unicidad de referencia, clave de solicitud y posición de revisión. Escritos en una transacción, con bloqueo de compañía y contador para garantizar coherencia y numeración global. + +Los activos/puestos y recursos se consultan mediante servicios públicos de sus módulos; no se importan modelos ORM ajenos. Repositorio y mapper propios en Infrastructure, puertos y adaptación en Application. + +API autenticada: +- GET /api/tickets: listado global. +- GET /api/tickets/:companyId: listado de compañía. +- GET /api/tickets/:companyId/lookups: opciones de esa compañía. +- POST /api/tickets/:companyId: alta. +- POST /api/tickets/:companyId/:id/update: edición. + +Entradas y salidas estrictas. Escrituras protegidas por sesión, CSRF y origen. La compañía del ticket y las referencias se validan en el servidor. + +## 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. diff --git a/modules/assets/src/api/module.ts b/modules/assets/src/api/module.ts index ea57310..b6c56bb 100644 --- a/modules/assets/src/api/module.ts +++ b/modules/assets/src/api/module.ts @@ -55,6 +55,17 @@ export async function createAssetModule(database: Sequelize, authenticate: Reque return { services: { inventory: { + workstations: async (companyId: UniqueID) => { + const result = await assetRepository.get(companyId); + if (!result.isSuccess) { + throw new Error("Puestos no disponibles."); + } + return result.data.workstations.map((item) => ({ + id: item.id, + name: item.values.toPrimitive().name, + active: item.values.toPrimitive().active, + })); + }, list: async (companyId: UniqueID) => { const result = await assetRepository.get(companyId); if (!result.isSuccess) { diff --git a/modules/assets/src/web/index.ts b/modules/assets/src/web/index.ts index 71676d0..5b78ae8 100644 --- a/modules/assets/src/web/index.ts +++ b/modules/assets/src/web/index.ts @@ -5,4 +5,5 @@ export { AssetCatalogPage } from "./catalog-page"; export { assetsEnglish, assetsSpanish } from "./i18n"; export { inventoryApi, inventoryKeys } from "./inventory-api"; export { AssetInventoryPage } from "./inventory-page"; +export { WorkstationForm } from "./workstation-form"; export { WorkstationsPage } from "./workstations-page"; diff --git a/modules/companies/package.json b/modules/companies/package.json index b6ae402..5986792 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" + "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" }, "dependencies": { "@repo/rdx-ddd": "workspace:*", diff --git a/modules/companies/src/api/application/ticket-input.mapper.ts b/modules/companies/src/api/application/ticket-input.mapper.ts new file mode 100644 index 0000000..c11ec32 --- /dev/null +++ b/modules/companies/src/api/application/ticket-input.mapper.ts @@ -0,0 +1,43 @@ +import { Result } from "@repo/rdx-utils"; + +import type { Ticket } from "../domain/ticket.aggregate"; +import { TicketError, type TicketInput } from "../domain/ticket-values"; +import { TicketValues } from "../domain/value-objects/ticket-values"; + +import { type TicketOption, type TicketSaveContext, targetOptions } from "./ticket.repository"; + +function available( + options: readonly TicketOption[], + next: string | null, + current: string | null | undefined +) { + if (next === null) { + return true; + } + const item = options.find((option) => option.id.toPrimitive() === next); + return Boolean(item && (item.active || next === current)); +} +export function mapTicketInput(input: TicketInput, context: TicketSaveContext, previous?: Ticket) { + const values = TicketValues.create(input); + if (!values.isSuccess) { + return values; + } + const next = values.data.toPrimitive(); + const current = previous?.values.toPrimitive(); + if (!available(context.lookups.people, next.requesterId, current?.requesterId)) { + return Result.fail(new TicketError("REQUESTER_UNAVAILABLE")); + } + if (!available(context.technicians, next.assigneeId, current?.assigneeId)) { + return Result.fail(new TicketError("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 TicketError("TARGET_UNAVAILABLE")); + } + } + return values; +} diff --git a/modules/companies/src/api/application/ticket-response.builder.ts b/modules/companies/src/api/application/ticket-response.builder.ts new file mode 100644 index 0000000..f471342 --- /dev/null +++ b/modules/companies/src/api/application/ticket-response.builder.ts @@ -0,0 +1,46 @@ +import type { TicketDTO, TicketListDTO, TicketLookupsDTO } from "../../common/tickets"; +import type { Ticket } from "../domain/ticket.aggregate"; + +import type { TicketContext, TicketLookups, TicketOption } from "./ticket.repository"; + +function option(item: TicketOption) { + return { id: item.id.toPrimitive(), name: item.name, active: item.active }; +} +export function buildTicket(ticket: Ticket, context: TicketContext): TicketDTO { + const values = ticket.values.toPrimitive(); + return { + id: ticket.id.toPrimitive(), + companyId: ticket.companyId.toPrimitive(), + companyName: + context.companies.find((item) => item.id.equals(ticket.companyId))?.name ?? + ticket.companyId.toPrimitive(), + reference: ticket.reference.toPrimitive(), + values, + requesterName: + context.people.find((item) => item.id.toPrimitive() === values.requesterId)?.name ?? null, + assigneeName: + context.technicians.find((item) => item.id.toPrimitive() === values.assigneeId)?.name ?? null, + history: ticket.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 buildTickets(context: TicketContext): TicketListDTO { + return { + tickets: context.tickets.map((ticket) => buildTicket(ticket, context)), + companies: context.companies.map(option), + technicians: context.technicians.map(option), + }; +} +export function buildTicketLookups(context: TicketLookups): TicketLookupsDTO { + return { + people: context.people.map(option), + assets: context.assets.map(option), + resources: context.resources.map(option), + workstations: context.workstations.map(option), + }; +} diff --git a/modules/companies/src/api/application/ticket.repository.ts b/modules/companies/src/api/application/ticket.repository.ts new file mode 100644 index 0000000..ade0965 --- /dev/null +++ b/modules/companies/src/api/application/ticket.repository.ts @@ -0,0 +1,51 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import type { Result } from "@repo/rdx-utils"; + +import type { Ticket } from "../domain/ticket.aggregate"; +import type { TicketError, TicketTargetInput } from "../domain/ticket-values"; +export type TicketOption = Readonly<{ id: UniqueID; name: string; active: boolean }>; +export type TicketContext = Readonly<{ + tickets: readonly Ticket[]; + companies: readonly TicketOption[]; + technicians: readonly TicketOption[]; + people: readonly (TicketOption & { companyId: UniqueID })[]; + actorNames: ReadonlyMap; +}>; +export type TicketLookups = Readonly<{ + people: readonly TicketOption[]; + assets: readonly TicketOption[]; + resources: readonly TicketOption[]; + workstations: readonly TicketOption[]; +}>; +export type TicketSaveContext = TicketContext & { + lookups: TicketLookups; + nextNumber: number; + requestTicket: Ticket | undefined; +}; +export interface ITicketInventoryService { + list(company: UniqueID): Promise; + workstations( + company: UniqueID + ): Promise; +} +export interface ITicketRepository { + list(company?: UniqueID): Promise>; + lookups(company: UniqueID): Promise>; + save( + company: UniqueID, + requestKey: UniqueID, + action: (context: TicketSaveContext) => Result + ): Promise>; +} +export function targetOptions(lookups: TicketLookups, kind: TicketTargetInput["kind"]) { + switch (kind) { + case "person": + return lookups.people; + case "asset": + return lookups.assets; + case "resource": + return lookups.resources; + case "workstation": + return lookups.workstations; + } +} diff --git a/modules/companies/src/api/application/ticket.use-cases.ts b/modules/companies/src/api/application/ticket.use-cases.ts new file mode 100644 index 0000000..2520efa --- /dev/null +++ b/modules/companies/src/api/application/ticket.use-cases.ts @@ -0,0 +1,97 @@ +import { Name, UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import type { SaveTicketInput } from "../../common/tickets"; +import { Ticket } from "../domain/ticket.aggregate"; +import { TicketError } from "../domain/ticket-values"; +import { TicketValues } from "../domain/value-objects/ticket-values"; + +import type { ITicketRepository } from "./ticket.repository"; +import { mapTicketInput } from "./ticket-input.mapper"; +export class TicketUseCases { + constructor(private readonly repository: ITicketRepository) {} + list(companyId?: string) { + const company = companyId ? UniqueID.create(companyId) : undefined; + if (company && !company.isSuccess) { + return Promise.resolve(Result.fail(new TicketError("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 TicketError("INVALID_REQUEST"))); + } + save(companyId: string, id: string | undefined, input: SaveTicketInput, actorId: string) { + 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 TicketError("INVALID_REQUEST"))); + } + const normalizedId = identity?.isSuccess ? identity.data.toPrimitive() : undefined; + return this.repository.save(company.data, key.data, (context) => { + const previous = context.tickets.find((item) => item.id.toPrimitive() === normalizedId); + if (id && !previous) { + return Result.fail(new TicketError("TICKET_NOT_FOUND")); + } + if (!id && context.requestTicket) { + return replayTicket(context.requestTicket, company.data, actor.data, input); + } + if (previous && previous.history.at(-1)?.id.toPrimitive() !== input.expectedRevisionId) { + return Result.fail(new TicketError("TICKET_CHANGED")); + } + const values = mapTicketInput(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); + } + return createTicket(company.data, key.data, values.data, actor.data, context.nextNumber, now); + }); + } +} + +function replayTicket( + original: Ticket, + company: UniqueID, + actor: UniqueID, + input: SaveTicketInput +): Result { + const values = TicketValues.create(input.values); + const first = original.history[0]; + if ( + !( + original.companyId.equals(company) && + first?.actorId.equals(actor) && + values.isSuccess && + first.values.equals(values.data) + ) + ) { + return Result.fail(new TicketError("REQUEST_KEY_REUSED")); + } + return Result.ok(original); +} + +function createTicket( + company: UniqueID, + key: UniqueID, + values: TicketValues, + actor: UniqueID, + number: number, + now: string +): Result { + const reference = Name.create(`T-${String(number).padStart(6, "0")}`, 40); + if (!reference.isSuccess) { + return Result.fail(new TicketError("INVALID_REQUEST")); + } + return Ticket.create(company, reference.data, key, values, actor, now); +} diff --git a/modules/companies/src/api/domain/ticket-revision.entity.ts b/modules/companies/src/api/domain/ticket-revision.entity.ts new file mode 100644 index 0000000..acba0cb --- /dev/null +++ b/modules/companies/src/api/domain/ticket-revision.entity.ts @@ -0,0 +1,25 @@ +import { DomainEntity, type UniqueID } from "@repo/rdx-ddd"; + +import type { TicketValues } from "./value-objects/ticket-values"; +export type TicketRevisionProps = Readonly<{ + values: TicketValues; + actorId: UniqueID; + recordedAt: string; +}>; +export class TicketRevision extends DomainEntity { + static create(props: TicketRevisionProps) { + return new TicketRevision(Object.freeze({ ...props })); + } + static rehydrate(props: TicketRevisionProps, id: UniqueID) { + return new TicketRevision(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/ticket-values.ts b/modules/companies/src/api/domain/ticket-values.ts new file mode 100644 index 0000000..4f2efd7 --- /dev/null +++ b/modules/companies/src/api/domain/ticket-values.ts @@ -0,0 +1,37 @@ +import { z } from "zod/v4"; +export const TicketStateSchema = z.enum([ + "open", + "in_progress", + "waiting", + "resolved", + "cancelled", +]); +export const TicketPrioritySchema = z.enum(["low", "normal", "high", "urgent"]); +export const TicketChannelSchema = z.enum(["email", "phone", "internal", "other"]); +export const TicketTargetSchema = z.strictObject({ + kind: z.enum(["person", "workstation", "asset", "resource"]), + targetId: z.uuid(), +}); +export const TicketInputSchema = z + .strictObject({ + subject: z.string().trim().min(1).max(200), + description: z.string().trim().max(10000), + requesterId: z.uuid().nullable(), + assigneeId: z.uuid().nullable(), + channel: TicketChannelSchema, + state: TicketStateSchema, + priority: TicketPrioritySchema, + affected: z.array(TicketTargetSchema).max(200), + }) + .refine( + (value) => + new Set(value.affected.map((item) => `${item.kind}:${item.targetId.toLowerCase()}`)).size === + value.affected.length + ); +export type TicketInput = z.infer; +export type TicketTargetInput = z.infer; +export class TicketError extends Error { + constructor(public readonly code: string) { + super(code); + } +} diff --git a/modules/companies/src/api/domain/ticket.aggregate.ts b/modules/companies/src/api/domain/ticket.aggregate.ts new file mode 100644 index 0000000..c3156c9 --- /dev/null +++ b/modules/companies/src/api/domain/ticket.aggregate.ts @@ -0,0 +1,80 @@ +import { AggregateRoot, type Name, type UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import { z } from "zod/v4"; + +import { TicketRevision } from "./ticket-revision.entity"; +import { TicketError } from "./ticket-values"; +import type { TicketValues } from "./value-objects/ticket-values"; + +type TicketProps = Readonly<{ + companyId: UniqueID; + reference: Name; + requestKey: UniqueID; + values: TicketValues; + history: readonly TicketRevision[]; +}>; +export class Ticket extends AggregateRoot { + private constructor(props: TicketProps, id?: UniqueID) { + super(Object.freeze({ ...props, history: Object.freeze([...props.history]) }), id); + } + static create( + companyId: UniqueID, + reference: Name, + requestKey: UniqueID, + values: TicketValues, + actorId: UniqueID, + recordedAt: string + ): Result { + if (!z.iso.datetime().safeParse(recordedAt).success) { + return Result.fail(new TicketError("INVALID_REQUEST")); + } + return Result.ok( + new Ticket({ + companyId, + reference, + requestKey, + values, + history: [TicketRevision.create({ values, actorId, recordedAt })], + }) + ); + } + static rehydrate(props: TicketProps, id: UniqueID) { + return new Ticket(props, id); + } + 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: TicketValues, actorId: UniqueID, recordedAt: string): Result { + if (this.values.equals(values)) { + return Result.ok(this); + } + if ( + !z.iso.datetime().safeParse(recordedAt).success || + recordedAt < (this.history.at(-1)?.recordedAt ?? "") + ) { + return Result.fail(new TicketError("INVALID_REQUEST")); + } + return Result.ok( + new Ticket( + { + ...this.props, + values, + history: [...this.history, TicketRevision.create({ values, actorId, recordedAt })], + }, + this.id + ) + ); + } +} diff --git a/modules/companies/src/api/domain/ticket.test.ts b/modules/companies/src/api/domain/ticket.test.ts new file mode 100644 index 0000000..4a4865b --- /dev/null +++ b/modules/companies/src/api/domain/ticket.test.ts @@ -0,0 +1,167 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { Name, UniqueID } from "@repo/rdx-ddd"; + +import { mapTicketInput } from "../application/ticket-input.mapper"; +import { TicketPersistenceMapper } from "../infrastructure/persistence/ticket-persistence.mapper"; + +import { Ticket } from "./ticket.aggregate"; +import { TicketInputSchema } from "./ticket-values"; +import { TicketValues } from "./value-objects/ticket-values"; + +const company = UniqueID.generateNewID(); +const actor = UniqueID.generateNewID(); +const target = UniqueID.generateNewID(); +const input = { + subject: "Revisar correo", + description: "", + requesterId: null, + assigneeId: null, + channel: "email" as const, + state: "open" as const, + priority: "normal" as const, + affected: [], +}; +function ticket() { + const values = TicketValues.create(input); + const reference = Name.create("T-000001", 40); + assert.ok(values.isSuccess && reference.isSuccess); + const result = Ticket.create( + company, + reference.data, + UniqueID.generateNewID(), + values.data, + actor, + "2026-10-07T12:00:00.000Z" + ); + assert.ok(result.isSuccess); + return result.data; +} +test("tickets: identidad, historial, idempotencia semántica y fecha", () => { + const original = ticket(); + assert.equal(original.id.toPrimitive()[14], "7"); + assert.equal(original.history[0]?.id.toPrimitive()[14], "7"); + const same = original.update(original.values, actor, "2026-10-07T13:00:00.000Z"); + assert.ok(same.isSuccess); + assert.equal(same.data, original); + const changed = TicketValues.create({ ...input, state: "resolved" }); + assert.ok(changed.isSuccess); + assert.equal(original.update(changed.data, actor, "2026-10-06T12:00:00.000Z").isSuccess, false); + const updated = original.update(changed.data, actor, "2026-10-07T13:00:00.000Z"); + assert.ok(updated.isSuccess); + assert.ok(updated.data.id.equals(original.id)); + assert.equal(updated.data.reference.toPrimitive(), "T-000001"); + assert.equal(updated.data.history.length, 2); + assert.equal(original.values.toPrimitive().state, "open"); +}); +test("tickets: entradas estrictas, campos opcionales y afectados únicos", () => { + assert.equal(TicketInputSchema.safeParse({ ...input, subject: " " }).success, false); + assert.equal(TicketInputSchema.safeParse({ ...input, subject: "x".repeat(201) }).success, false); + assert.equal( + TicketInputSchema.safeParse({ ...input, companyId: company.toPrimitive() }).success, + false + ); + assert.equal( + TicketInputSchema.safeParse({ + ...input, + affected: [ + { kind: "asset", targetId: target.toPrimitive() }, + { kind: "asset", targetId: target.toPrimitive() }, + ], + }).success, + false + ); + assert.equal( + TicketInputSchema.safeParse({ + ...input, + affected: [{ kind: "other", targetId: target.toPrimitive() }], + }).success, + false + ); + assert.ok(TicketInputSchema.safeParse(input).success); +}); +test("tickets: afectados son referencias semánticas sin orden", () => { + const first = TicketValues.create({ + ...input, + affected: [ + { kind: "asset", targetId: target.toPrimitive() }, + { kind: "person", targetId: actor.toPrimitive() }, + ], + }); + const second = TicketValues.create({ + ...input, + affected: [ + { kind: "person", targetId: actor.toPrimitive() }, + { kind: "asset", targetId: target.toPrimitive() }, + ], + }); + assert.ok(first.isSuccess && second.isSuccess); + assert.ok(first.data.equals(second.data)); +}); +test("tickets: pertenencia, inactividad y conservación de referencias existentes", () => { + const context = { + tickets: [], + companies: [], + technicians: [], + people: [], + actorNames: new Map(), + nextNumber: 1, + requestTicket: undefined, + lookups: { + people: [], + assets: [{ id: target, name: "Portátil", active: true }], + resources: [], + workstations: [], + }, + }; + assert.ok( + mapTicketInput( + { ...input, affected: [{ kind: "asset", targetId: target.toPrimitive() }] }, + context + ).isSuccess + ); + assert.equal( + mapTicketInput({ ...input, requesterId: actor.toPrimitive() }, context).isSuccess, + false + ); + assert.equal( + mapTicketInput({ ...input, assigneeId: actor.toPrimitive() }, context).isSuccess, + false + ); + assert.equal( + mapTicketInput( + { ...input, affected: [{ kind: "person", targetId: target.toPrimitive() }] }, + context + ).isSuccess, + false + ); + const unavailable = { + ...context, + lookups: { ...context.lookups, assets: [{ id: target, name: "Portátil", active: false }] }, + }; + assert.equal( + mapTicketInput( + { ...input, affected: [{ kind: "asset", targetId: target.toPrimitive() }] }, + unavailable + ).isSuccess, + false + ); +}); +test("tickets: mapper preserva revisiones y rechaza inconsistencia", () => { + const original = ticket(); + const mapper = new TicketPersistenceMapper(); + const rows = mapper.toPersistence(original); + const read = mapper.toDomain(rows.root, rows.revisions); + assert.ok(read.id.equals(original.id)); + assert.ok(read.requestKey.equals(original.requestKey)); + assert.ok(read.values.equals(original.values)); + assert.throws(() => mapper.toDomain(rows.root, [])); + assert.throws(() => mapper.toDomain({ ...rows.root, reference: "BAD" }, rows.revisions)); + assert.throws(() => + mapper.toDomain( + rows.root, + rows.revisions.map((row) => ({ ...row, position: 2 })) + ) + ); +}); diff --git a/modules/companies/src/api/domain/value-objects/ticket-values.ts b/modules/companies/src/api/domain/value-objects/ticket-values.ts new file mode 100644 index 0000000..1312583 --- /dev/null +++ b/modules/companies/src/api/domain/value-objects/ticket-values.ts @@ -0,0 +1,97 @@ +import { Name, UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { + TicketError, + type TicketInput, + TicketInputSchema, + type TicketTargetInput, +} from "../ticket-values"; +export class TicketTarget { + private constructor( + public readonly kind: TicketTargetInput["kind"], + public readonly targetId: UniqueID + ) { + Object.freeze(this); + } + static create(input: TicketTargetInput): Result { + const id = UniqueID.create(input.targetId); + if (!id.isSuccess) { + return Result.fail(new TicketError("INVALID_REQUEST")); + } + return Result.ok(new TicketTarget(input.kind, id.data)); + } + toPrimitive(): TicketTargetInput { + return { kind: this.kind, targetId: this.targetId.toPrimitive() }; + } + equals(other: TicketTarget) { + return this.kind === other.kind && this.targetId.equals(other.targetId); + } +} +function optionalId(value: string | null): Result, TicketError> { + if (value === null) { + return Result.ok(Maybe.none()); + } + const id = UniqueID.create(value); + return id.isSuccess + ? Result.ok(Maybe.some(id.data)) + : Result.fail(new TicketError("INVALID_REQUEST")); +} +export class TicketValues { + private constructor( + public readonly subject: Name, + public readonly requesterId: Maybe, + public readonly assigneeId: Maybe, + private readonly data: Omit, + public readonly affected: readonly TicketTarget[] + ) { + Object.freeze(data); + Object.freeze(affected); + Object.freeze(this); + } + static create(input: TicketInput): Result { + const parsed = TicketInputSchema.safeParse(input); + const name = Name.create(input.subject, 200); + const person = optionalId(input.requesterId); + const assignee = optionalId(input.assigneeId); + if (!(parsed.success && name.isSuccess && person.isSuccess && assignee.isSuccess)) { + return Result.fail(new TicketError("INVALID_REQUEST")); + } + const targets: TicketTarget[] = []; + for (const item of parsed.data.affected) { + const target = TicketTarget.create(item); + if (!target.isSuccess) { + return Result.fail(target.error); + } + targets.push(target.data); + } + const { subject: _, requesterId: __, assigneeId: ___, affected: ____, ...data } = parsed.data; + return Result.ok(new TicketValues(name.data, person.data, assignee.data, data, targets)); + } + toPrimitive(): TicketInput { + return { + ...this.data, + subject: this.subject.toPrimitive(), + requesterId: this.requesterId.getOrUndefined()?.toPrimitive() ?? null, + assigneeId: this.assigneeId.getOrUndefined()?.toPrimitive() ?? null, + affected: this.affected.map((item) => item.toPrimitive()), + }; + } + equals(other: TicketValues) { + const a = this.data; + const b = other.data; + return ( + this.subject.equals(other.subject) && + this.requesterId.getOrUndefined()?.toPrimitive() === + other.requesterId.getOrUndefined()?.toPrimitive() && + this.assigneeId.getOrUndefined()?.toPrimitive() === + other.assigneeId.getOrUndefined()?.toPrimitive() && + a.description === b.description && + a.channel === b.channel && + a.state === b.state && + a.priority === b.priority && + this.affected.length === other.affected.length && + this.affected.every((target) => other.affected.some((item) => target.equals(item))) + ); + } +} diff --git a/modules/companies/src/api/infrastructure/http/ticket.routes.ts b/modules/companies/src/api/infrastructure/http/ticket.routes.ts new file mode 100644 index 0000000..7d1fb14 --- /dev/null +++ b/modules/companies/src/api/infrastructure/http/ticket.routes.ts @@ -0,0 +1,98 @@ +import type { Application, RequestHandler, Response } from "express"; +import { Router } from "express"; +import { z } from "zod/v4"; + +import { SaveTicketSchema } from "../../../common/tickets"; +import type { TicketUseCases } from "../../application/ticket.use-cases"; +import { + buildTicket, + buildTicketLookups, + buildTickets, +} from "../../application/ticket-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 registerTicketRoutes( + app: Application, + useCases: TicketUseCases, + 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(buildTicketLookups(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(buildTickets(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 = SaveTicketSchema.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({ ticket: buildTicket(result.data.ticket, result.data.context) }); + }) + .catch(next); + }); + } + app.use("/api/tickets", authenticate, router); +} diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize-ticket.repository.ts b/modules/companies/src/api/infrastructure/persistence/sequelize-ticket.repository.ts new file mode 100644 index 0000000..4da77c3 --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/sequelize-ticket.repository.ts @@ -0,0 +1,189 @@ +import type { IResourceInventoryService } from "@metis/resources/services"; +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 { + ITicketInventoryService, + ITicketRepository, + TicketContext, + TicketLookups, + TicketSaveContext, +} from "../../application/ticket.repository"; +import type { Ticket } from "../../domain/ticket.aggregate"; +import { TicketError } from "../../domain/ticket-values"; + +import { TicketPersistenceMapper, ticketIdentity } from "./ticket-persistence.mapper"; +export class SequelizeTicketRepository implements ITicketRepository { + private readonly mapper = new TicketPersistenceMapper(); + constructor( + private readonly database: Sequelize, + private readonly inventory: ITicketInventoryService, + private readonly resources: IResourceInventoryService + ) {} + private async read( + company: UniqueID | undefined, + transaction: Transaction + ): Promise> { + const companies = await this.database.query<{ id: string; name: string; active: boolean }>( + "SELECT id,name,active FROM companies ORDER BY name", + { transaction, type: QueryTypes.SELECT } + ); + if (company && !companies.some((item) => item.id === company.toPrimitive())) { + return Result.fail(new TicketError("COMPANY_NOT_FOUND")); + } + const users = await this.database.query<{ id: string; name: string; active: boolean }>( + "SELECT id,name,active FROM users ORDER BY name", + { transaction, type: QueryTypes.SELECT } + ); + const people = await this.database.query<{ + id: string; + companyId: string; + name: string; + active: boolean; + }>("SELECT id,companyId,name,active FROM company_contacts", { + transaction, + type: QueryTypes.SELECT, + }); + const where = company ? " WHERE companyId=:companyId" : ""; + const roots = await this.database.query>( + "SELECT id,companyId,reference,requestKey,requesterId,assigneeId,`values` FROM tickets" + + where, + { + transaction, + type: QueryTypes.SELECT, + replacements: { companyId: company?.toPrimitive() ?? null }, + } + ); + const revisions = await this.database.query>( + "SELECT r.id,r.ticketId,r.actorId,r.requesterId,r.assigneeId,r.`values`,r.recordedAt,r.position FROM ticket_revisions r JOIN tickets t ON t.id=r.ticketId" + + (company ? " WHERE t.companyId=:companyId" : ""), + { + transaction, + type: QueryTypes.SELECT, + replacements: { companyId: company?.toPrimitive() ?? null }, + } + ); + const option = (item: { id: string; name: string; active: boolean }) => ({ + id: ticketIdentity(item.id), + name: item.name, + active: Boolean(item.active), + }); + return Result.ok({ + companies: companies.map(option), + technicians: users.map(option), + people: people.map((item) => ({ + ...option(item), + companyId: ticketIdentity(item.companyId), + })), + actorNames: new Map(users.map((item) => [item.id, item.name])), + tickets: roots.map((row) => + this.mapper.toDomain( + row, + revisions.filter((item) => item.ticketId === row.id) + ) + ), + }); + } + list(company?: UniqueID) { + return this.database.transaction((transaction) => this.read(company, transaction)); + } + async lookups(company: UniqueID): Promise> { + const rows = await this.database.query<{ id: string }>( + "SELECT id FROM companies WHERE id=:id", + { replacements: { id: company.toPrimitive() }, type: QueryTypes.SELECT } + ); + if (!rows.length) { + return Result.fail(new TicketError("COMPANY_NOT_FOUND")); + } + return Result.ok(await this.readLookups(company)); + } + private async readLookups(company: UniqueID): Promise { + const people = await this.database.query<{ id: string; name: string; active: boolean }>( + "SELECT id,name,active FROM company_contacts WHERE companyId=:id", + { replacements: { id: company.toPrimitive() }, type: QueryTypes.SELECT } + ); + const [assets, resources, workstations] = await Promise.all([ + this.inventory.list(company), + this.resources.list(company), + this.inventory.workstations(company), + ]); + return { + people: people.map((item) => ({ + id: ticketIdentity(item.id), + name: item.name, + active: Boolean(item.active), + })), + assets: assets.map((item) => ({ id: item.id, name: item.name, active: item.available })), + resources: resources.map((item) => ({ id: item.id, name: item.name, active: item.active })), + workstations, + }; + } + save( + company: UniqueID, + requestKey: UniqueID, + action: (context: TicketSaveContext) => 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 ticket_counter WHERE id=1 FOR UPDATE", { + transaction, + type: QueryTypes.SELECT, + }); + const context = await this.read(undefined, transaction); + if (!context.isSuccess) { + return Result.fail(context.error); + } + if (!context.data.companies.some((item) => item.id.equals(company))) { + return Result.fail(new TicketError("COMPANY_NOT_FOUND")); + } + const sequence = await this.database.query<{ nextNumber: number }>( + "SELECT nextNumber FROM ticket_counter WHERE id=1", + { transaction, type: QueryTypes.SELECT } + ); + const nextNumber = z.number().int().positive().parse(sequence[0]?.nextNumber); + const scoped = { + ...context.data, + tickets: context.data.tickets.filter((item) => item.companyId.equals(company)), + lookups: await this.readLookups(company), + nextNumber, + requestTicket: context.data.tickets.find((item) => item.requestKey.equals(requestKey)), + }; + const result = action(scoped); + if (!result.isSuccess) { + return Result.fail(result.error); + } + const previous = context.data.tickets.find((item) => item.id.equals(result.data.id)); + await this.write(result.data, previous, transaction); + return Result.ok({ ticket: result.data, context: context.data }); + }); + } + private async write(ticket: Ticket, previous: Ticket | undefined, transaction: Transaction) { + const data = this.mapper.toPersistence(ticket); + await this.database.query( + previous + ? "UPDATE tickets SET requesterId=:requesterId,assigneeId=:assigneeId,`values`=:values WHERE id=:id" + : "INSERT INTO tickets (id,companyId,reference,requestKey,requesterId,assigneeId,`values`) VALUES (:id,:companyId,:reference,:requestKey,:requesterId,:assigneeId,:values)", + { replacements: data.root, transaction } + ); + const existing = new Set(previous?.history.map((item) => item.id.toPrimitive()) ?? []); + for (const row of data.revisions) { + if (!existing.has(row.id)) { + await this.database.query( + "INSERT INTO ticket_revisions (id,ticketId,actorId,requesterId,assigneeId,`values`,recordedAt,position) VALUES (:id,:ticketId,:actorId,:requesterId,:assigneeId,:values,:recordedAt,:position)", + { replacements: row, transaction } + ); + } + } + if (!previous) { + await this.database.query("UPDATE ticket_counter SET nextNumber=nextNumber+1 WHERE id=1", { + transaction, + }); + } + } +} diff --git a/modules/companies/src/api/infrastructure/persistence/ticket-migration.ts b/modules/companies/src/api/infrastructure/persistence/ticket-migration.ts new file mode 100644 index 0000000..6a659ec --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/ticket-migration.ts @@ -0,0 +1,64 @@ +import { DataTypes, QueryTypes, type Sequelize } from "sequelize"; +export async function migrateTickets(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 = [ + [ + "ticket_counter", + { + id: { type: DataTypes.INTEGER, primaryKey: true }, + nextNumber: { type: DataTypes.INTEGER, allowNull: false }, + }, + ], + [ + "tickets", + { + 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 }, + requesterId: fk("company_contacts", true), + assigneeId: fk("users", true), + values: { type: DataTypes.JSON, allowNull: false }, + }, + ], + [ + "ticket_revisions", + { + id: { type: DataTypes.UUID, primaryKey: true }, + ticketId: fk("tickets"), + actorId: fk("users"), + requesterId: fk("company_contacts", 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 ticket_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='ticket_revisions'", + { type: QueryTypes.SELECT } + ); + if (!indexes.some((item) => item.name === "ticket_revision_position")) { + await query.addIndex("ticket_revisions", ["ticketId", "position"], { + name: "ticket_revision_position", + unique: true, + }); + } +} diff --git a/modules/companies/src/api/infrastructure/persistence/ticket-persistence.mapper.ts b/modules/companies/src/api/infrastructure/persistence/ticket-persistence.mapper.ts new file mode 100644 index 0000000..3cdc561 --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/ticket-persistence.mapper.ts @@ -0,0 +1,117 @@ +import { Name, UniqueID } from "@repo/rdx-ddd"; +import { z } from "zod/v4"; + +import { Ticket } from "../../domain/ticket.aggregate"; +import { TicketRevision } from "../../domain/ticket-revision.entity"; +import { TicketInputSchema } from "../../domain/ticket-values"; +import { TicketValues } from "../../domain/value-objects/ticket-values"; + +const Root = z.strictObject({ + id: z.uuid(), + companyId: z.uuid(), + reference: z.string().regex(/^T-[0-9]{6,}$/), + requestKey: z.uuid(), + requesterId: z.uuid().nullable(), + assigneeId: z.uuid().nullable(), + values: TicketInputSchema, +}); +const Revision = z.strictObject({ + id: z.uuid(), + ticketId: z.uuid(), + actorId: z.uuid(), + requesterId: z.uuid().nullable(), + assigneeId: z.uuid().nullable(), + values: TicketInputSchema, + recordedAt: z.iso.datetime(), + position: z.number().int().nonnegative(), +}); +export function ticketIdentity(value: string) { + const id = UniqueID.create(value); + if (!id.isSuccess) { + throw new Error("Identidad de ticket inconsistente."); + } + return id.data; +} +function json(value: unknown): unknown { + return typeof value === "string" ? JSON.parse(value) : value; +} +function values(value: unknown) { + const result = TicketValues.create(TicketInputSchema.parse(json(value))); + if (!result.isSuccess) { + throw new Error("Valores de ticket inconsistentes."); + } + return result.data; +} +export class TicketPersistenceMapper { + toDomain(root: Record, rows: readonly Record[]) { + const current = Root.parse({ ...root, values: json(root.values) }); + 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.ticketId !== current.id || + item.position !== index || + item.requesterId !== item.values.requesterId || + item.assigneeId !== item.values.assigneeId || + (index > 0 && item.recordedAt < (history[index - 1]?.recordedAt ?? "")) + ) || + current.requesterId !== current.values.requesterId || + current.assigneeId !== current.values.assigneeId + ) { + throw new Error("Historial de ticket inconsistente."); + } + const revisions = history.map((item) => + TicketRevision.rehydrate( + { + values: values(item.values), + actorId: ticketIdentity(item.actorId), + recordedAt: item.recordedAt, + }, + ticketIdentity(item.id) + ) + ); + const currentValues = values(current.values); + if (!revisions.at(-1)?.values.equals(currentValues)) { + throw new Error("Instantánea de ticket inconsistente."); + } + return Ticket.rehydrate( + { + companyId: ticketIdentity(current.companyId), + reference: reference.data, + requestKey: ticketIdentity(current.requestKey), + values: currentValues, + history: revisions, + }, + ticketIdentity(current.id) + ); + } + toPersistence(ticket: Ticket) { + const input = ticket.values.toPrimitive(); + return { + root: { + id: ticket.id.toPrimitive(), + companyId: ticket.companyId.toPrimitive(), + reference: ticket.reference.toPrimitive(), + requestKey: ticket.requestKey.toPrimitive(), + requesterId: input.requesterId, + assigneeId: input.assigneeId, + values: JSON.stringify(input), + }, + revisions: ticket.history.map((item, position) => ({ + id: item.id.toPrimitive(), + ticketId: ticket.id.toPrimitive(), + actorId: item.actorId.toPrimitive(), + requesterId: item.values.toPrimitive().requesterId, + assigneeId: item.values.toPrimitive().assigneeId, + values: JSON.stringify(item.values.toPrimitive()), + recordedAt: item.recordedAt, + position, + })), + }; + } +} diff --git a/modules/companies/src/api/module.ts b/modules/companies/src/api/module.ts index 1edc342..e834d51 100644 --- a/modules/companies/src/api/module.ts +++ b/modules/companies/src/api/module.ts @@ -16,6 +16,8 @@ import type { IProcessAssetInventoryService } from "./application/process.reposi import { ProcessUseCases } from "./application/process.use-cases"; import { RemoteConnectionUseCases } from "./application/remote-connection.use-cases"; import { TeleworkAuthorizationUseCases } from "./application/telework-authorization.use-cases"; +import type { ITicketInventoryService } from "./application/ticket.repository"; +import { TicketUseCases } from "./application/ticket.use-cases"; import type { IWorkProfileInventoryService } from "./application/work-profile.repository"; import { WorkProfileUseCases } from "./application/work-profile.use-cases"; import { registerAccessRoutes } from "./infrastructure/http/access.routes"; @@ -29,6 +31,7 @@ import { registerPrinterConfigurationRoutes } from "./infrastructure/http/printe import { registerProcessRoutes } from "./infrastructure/http/process.routes"; import { registerRemoteConnectionRoutes } from "./infrastructure/http/remote-connection.routes"; import { registerTeleworkAuthorizationRoutes } from "./infrastructure/http/telework-authorization.routes"; +import { registerTicketRoutes } from "./infrastructure/http/ticket.routes"; import { registerWorkProfileRoutes } from "./infrastructure/http/work-profile.routes"; import { AccessPersistenceMapper, @@ -58,8 +61,10 @@ import { SequelizePrinterConfigurationRepository } from "./infrastructure/persis import { SequelizeProcessRepository } from "./infrastructure/persistence/sequelize-process.repository"; import { SequelizeRemoteConnectionRepository } from "./infrastructure/persistence/sequelize-remote-connection.repository"; import { SequelizeTeleworkAuthorizationRepository } from "./infrastructure/persistence/sequelize-telework-authorization.repository"; +import { SequelizeTicketRepository } from "./infrastructure/persistence/sequelize-ticket.repository"; import { SequelizeWorkProfileRepository } from "./infrastructure/persistence/sequelize-work-profile.repository"; import { migrateTeleworkAuthorizations } from "./infrastructure/persistence/telework-authorization-migration"; +import { migrateTickets } from "./infrastructure/persistence/ticket-migration"; import { migrateWorkProfiles } from "./infrastructure/persistence/work-profile-migration"; export async function createCompanyModule( @@ -83,9 +88,15 @@ export async function createCompanyModule( const useCases = new CompanyUseCases(repository, new CompanyInputMapper()); return { initializeProfiles: async ( - inventory: IWorkProfileInventoryService & IProcessAssetInventoryService, + inventory: IWorkProfileInventoryService & + IProcessAssetInventoryService & + ITicketInventoryService, resources: IResourceInventoryService ) => { + await migrateTickets(database); + const tickets = new TicketUseCases( + new SequelizeTicketRepository(database, inventory, resources) + ); await migrateProcesses(database); const processes = new ProcessUseCases( new SequelizeProcessRepository(database, inventory, resources) @@ -116,6 +127,7 @@ export async function createCompanyModule( ); return { register: (app: Parameters[0]) => { + registerTicketRoutes(app, tickets, authenticate); registerProcessRoutes(app, processes, authenticate); registerRemoteConnectionRoutes(app, remote, authenticate); registerWorkProfileRoutes(app, profiles, authenticate); diff --git a/modules/companies/src/common/index.ts b/modules/companies/src/common/index.ts index d6554e9..936c8a6 100644 --- a/modules/companies/src/common/index.ts +++ b/modules/companies/src/common/index.ts @@ -14,4 +14,5 @@ export * from "./printer-configurations"; export * from "./processes"; export * from "./remote-connections"; export * from "./telework-authorizations"; +export * from "./tickets"; export * from "./work-profiles"; diff --git a/modules/companies/src/common/tickets.ts b/modules/companies/src/common/tickets.ts new file mode 100644 index 0000000..32292ba --- /dev/null +++ b/modules/companies/src/common/tickets.ts @@ -0,0 +1,54 @@ +import { z } from "zod/v4"; + +import { + TicketChannelSchema, + TicketInputSchema, + TicketPrioritySchema, + TicketStateSchema, +} from "../api/domain/ticket-values"; + +export type { TicketInput, TicketTargetInput } from "../api/domain/ticket-values"; + +export { TicketChannelSchema, TicketInputSchema, TicketPrioritySchema, TicketStateSchema }; +export const SaveTicketSchema = z.strictObject({ + values: TicketInputSchema, + requestKey: z.uuid(), + expectedRevisionId: z.uuid().nullable(), +}); +export type SaveTicketInput = z.infer; +export const TicketSchema = z.strictObject({ + id: z.uuid(), + companyId: z.uuid(), + companyName: z.string(), + reference: z.string(), + values: TicketInputSchema, + requesterName: z.string().nullable(), + assigneeName: z.string().nullable(), + history: z + .array( + z.strictObject({ + id: z.uuid(), + values: TicketInputSchema, + actorId: z.uuid(), + actorName: z.string(), + recordedAt: z.iso.datetime(), + }) + ) + .min(1), +}); +const OptionSchema = z.strictObject({ id: z.uuid(), name: z.string(), active: z.boolean() }); +export const TicketListSchema = z.strictObject({ + tickets: z.array(TicketSchema), + companies: z.array(OptionSchema), + technicians: z.array(OptionSchema), +}); +export const TicketResponseSchema = z.strictObject({ ticket: TicketSchema }); +export const TicketLookupsSchema = z.strictObject({ + people: z.array(OptionSchema), + assets: z.array(OptionSchema), + resources: z.array(OptionSchema), + workstations: z.array(OptionSchema), +}); +export type TicketDTO = z.infer; +export type TicketListDTO = z.infer; +export type TicketLookupsDTO = z.infer; diff --git a/modules/companies/src/web/components/companies-workspace.tsx b/modules/companies/src/web/components/companies-workspace.tsx index 56d6382..238aa4c 100644 --- a/modules/companies/src/web/components/companies-workspace.tsx +++ b/modules/companies/src/web/components/companies-workspace.tsx @@ -9,6 +9,7 @@ import { useTranslation } from "react-i18next"; import { AccessWorkspacePage } from "../access/access-workspace"; import { companyApi, companyKeys } from "../api/company-api"; +import { TicketsPage } from "../tickets/tickets-page"; import { CompaniesPage } from "./companies-page"; import { CompanyDetail } from "./company-detail"; @@ -16,6 +17,7 @@ import { CompanyFeedback } from "./company-feedback"; type WorkspaceLocation = | { area: "company"; id: string } + | { area: "tickets" } | { area: "directory" } | { area: "catalogs" }; @@ -62,6 +64,16 @@ export function CompaniesWorkspace({ >