diff --git a/apps/server/src/identity.test.ts b/apps/server/src/identity.test.ts index d589b64..ddfa133 100644 --- a/apps/server/src/identity.test.ts +++ b/apps/server/src/identity.test.ts @@ -29,6 +29,7 @@ import { createIdentityRuntime } from "./infrastructure/http/identity-runtime"; import { verifyInventory } from "./inventory.integration"; import { verifyMail } from "./mail.integration"; import { verifyPrinters } from "./printers.integration"; +import { verifyProcesses } from "./processes.integration"; import { verifyRelationships } from "./relationships.integration"; import { verifyRemoteConnections } from "./remote.integration"; import { verifyResourceMigration } from "./resource-migration.integration"; @@ -64,6 +65,12 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de await database.query("DELETE FROM application_configurations"); await database.query("DELETE FROM certificate_configuration_revisions"); await database.query("DELETE FROM certificate_configurations"); + await database.query("DELETE FROM person_process_revisions"); + await database.query("DELETE FROM person_processes"); + await database.query( + "DELETE r FROM process_template_revisions r JOIN process_templates t ON t.id=r.itemId WHERE t.builtinKey IS NULL" + ); + await database.query("DELETE FROM process_templates WHERE builtinKey IS NULL"); await database.query("DELETE FROM remote_connection_revisions"); await database.query("DELETE FROM remote_connections"); await database.query("DELETE FROM telework_authorization_revisions"); @@ -799,6 +806,12 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de secondCompany.id, database ); + await verifyProcesses( + (...args) => operator.request(...args), + customer.id, + secondCompany.id, + database + ); // 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"); @@ -882,6 +895,13 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de 401, "UNAUTHENTICATED" ); + await expectError(await operator.request(`/processes/${customer.id}`), 401, "UNAUTHENTICATED"); + await expectError(await operator.request("/processes/templates"), 401, "UNAUTHENTICATED"); + await expectError( + await operator.request(`/processes/${customer.id}`, {}), + 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/processes.integration.ts b/apps/server/src/processes.integration.ts new file mode 100644 index 0000000..2a6b6b5 --- /dev/null +++ b/apps/server/src/processes.integration.ts @@ -0,0 +1,283 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; + +import { InventoryResponseSchema as AssetsSchema } from "@metis/assets/common"; +import { + ApplicationConfigurationResponseSchema, + CompanyResponseSchema, + ProcessResponseSchema, + ProcessReviewResponseSchema, + type ProcessTemplateInput, + ProcessTemplateResponseSchema, + ProcessTemplatesResponseSchema, + ProcessesResponseSchema, + RemoteConnectionResponseSchema, + type UpdateProcessInput, + WorkProfileResponseSchema, +} from "@metis/companies/common"; +import { InventoryResponseSchema as ResourcesSchema } from "@metis/resources/common"; +import type { Sequelize } from "sequelize"; +import { z } from "zod/v4"; + +type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise; +export async function verifyProcesses( + request: Request, + companyId: string, + otherId: string, + database: Sequelize +) { + const path = `/processes/${companyId}`; + const templates = ProcessTemplatesResponseSchema.parse( + await (await request("/processes/templates")).json() + ).templates; + for (const kind of ["onboarding", "change", "offboarding"]) { + assert.ok(templates.some((t) => t.values.kind === kind && t.values.active)); + } + const templateValues: ProcessTemplateInput = { + name: "Proceso test salida", + kind: "offboarding", + active: true, + notes: null, + instructions: ["Revisar y documentar"], + }; + const templateResponse = await request("/processes/templates", { values: templateValues }); + assert.equal(templateResponse.status, 201); + const template = ProcessTemplateResponseSchema.parse(await templateResponse.json()).template; + assert.equal(template.id[14], "7"); + const personValues = { + name: "Persona proceso", + position: null, + email: null, + phone: null, + active: true, + isContact: false, + }; + const personResponse = await request(`/companies/${companyId}/people`, personValues); + const person = CompanyResponseSchema.parse(await personResponse.json()).company.people.at(-1); + assert.ok(person); + const host = AssetsSchema.parse(await (await request(`/assets/${companyId}`)).json()).assets.find( + (a) => a.values.status !== "retired" + ); + assert.ok(host); + assert.equal( + ( + await request(`/assets/${companyId}/${host.id}/update`, { + values: { ...host.values, personId: person.id }, + }) + ).status, + 200 + ); + const profileResponse = await request(`/work-profiles/${companyId}`, { + values: { + personId: person.id, + name: "Perfil proceso", + assetId: host.id, + accountId: null, + active: true, + notes: null, + }, + }); + assert.equal(profileResponse.status, 201); + const profile = WorkProfileResponseSchema.parse(await profileResponse.json()).profile; + const resource = ResourcesSchema.parse( + await (await request(`/resources/${companyId}`)).json() + ).resources.find((r) => r.values.active && r.specification.templateName === "Aplicación"); + assert.ok(resource); + const appValues = { + profileId: profile.id, + resourceId: resource.id, + delivery: "installed", + version: null, + state: "configured", + notes: null, + }; + const appResponse = await request(`/application-configurations/${companyId}`, { + values: appValues, + }); + assert.equal(appResponse.status, 201); + const app = ApplicationConfigurationResponseSchema.parse(await appResponse.json()).configuration; + const remoteValues = { + personId: person.id, + sourceKind: "personal_mobile", + sourceAssetId: null, + targetKind: "network", + targetId: null, + siteId: null, + method: "VPN móvil proceso", + requiresVpn: false, + state: "configured", + notes: null, + }; + const remoteResponse = await request(`/remote-connections/${companyId}`, { + values: remoteValues, + }); + assert.equal(remoteResponse.status, 201); + const remote = RemoteConnectionResponseSchema.parse(await remoteResponse.json()).connection; + const currentReview = ProcessReviewResponseSchema.parse( + await (await request(`${path}/people/${person.id}/review`)).json() + ).review; + assert.ok( + currentReview.some( + (r) => r.kind === "remote" && r.id === remote.id && r.sourceKind === "personal_mobile" + ) + ); + assert.ok(currentReview.some((r) => r.kind === "application" && r.id === app.id)); + assert.ok(currentReview.some((r) => r.kind === "asset" && r.id === host.id)); + assert.equal((await request(`/processes/${otherId}/people/${person.id}/review`)).status, 404); + const input = { + personId: person.id, + templateId: template.id, + name: "Salida persona proceso", + notes: null, + }; + const error = async (body: unknown, code: string, id?: string, status = 409) => { + const response = await request(path + (id ? `/${id}/update` : ""), { values: body }); + assert.equal(response.status, status); + assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code); + }; + await error({ ...input, actorId: person.id }, "INVALID_REQUEST", undefined, 400); + await error({ ...input, personId: randomUUID() }, "PROCESS_PERSON_NOT_FOUND", undefined, 404); + assert.equal((await request(path, { values: input }, "https://other.example")).status, 403); + assert.equal((await request(path, { values: input }, undefined, "b".repeat(64))).status, 403); + const concurrent = await Promise.all([ + request(path, { values: input }), + request(path, { values: input }), + ]); + assert.deepEqual(concurrent.map((r) => r.status).sort(), [201, 409]); + const createdResponse = concurrent.find((r) => r.status === 201); + assert.ok(createdResponse); + const process = ProcessResponseSchema.parse(await createdResponse.json()).process; + assert.equal(process.id[14], "7"); + assert.equal(process.history[0]?.id[14], "7"); + assert.ok(process.values.tasks.every((t) => t.id[14] === "7")); + assert.equal(process.values.tasks.length, currentReview.length + 1); + const revision = process.history.at(-1); + assert.ok(revision); + const edit: UpdateProcessInput = { + name: process.values.name, + notes: null, + state: "open", + revisionId: revision.id, + tasks: process.values.tasks.map((t) => ({ id: t.id, status: t.status, notes: t.notes })), + }; + const save = async (values: UpdateProcessInput) => { + const response = await request(`${path}/${process.id}/update`, { values }); + assert.equal(response.status, 200); + return ProcessResponseSchema.parse(await response.json()).process; + }; + const unchanged = await save(edit); + assert.deepEqual(unchanged, process); + await error({ ...edit, state: "completed" }, "PROCESS_PENDING_TASKS", process.id); + await error({ ...edit, tasks: edit.tasks.slice(1) }, "PROCESS_TASKS_IMMUTABLE", process.id); + assert.equal( + (await request(`/processes/${otherId}/${process.id}/update`, { values: edit })).status, + 404 + ); + assert.equal( + ProcessesResponseSchema.parse(await (await request(`/processes/${otherId}`)).json()).processes + .length, + 0 + ); + const templateUpdate = await request(`/processes/templates/${template.id}/update`, { + values: { ...templateValues, active: false, instructions: ["Otra plantilla"] }, + }); + assert.equal(templateUpdate.status, 200); + const resolved = await save({ + ...edit, + tasks: edit.tasks.map((t) => ({ + ...t, + status: "done", + notes: "Revisado; conserva acceso hasta entrega", + })), + }); + assert.equal(resolved.values.tasks[0]?.title, "Revisar y documentar"); + assert.equal(resolved.history.length, 2); + await error({ ...edit, notes: "Cambio perdido" }, "PROCESS_CONFLICT", process.id); + const inactivePerson = await request(`/companies/${companyId}/people/${person.id}/update`, { + ...personValues, + active: false, + }); + assert.equal(inactivePerson.status, 200); + const newRemote = await request(`/remote-connections/${companyId}`, { + values: { ...remoteValues, method: "Herramienta nueva proceso" }, + }); + assert.equal(newRemote.status, 201); + const refreshedResponse = await request(`${path}/${process.id}/refresh`, { + revisionId: resolved.history.at(-1)?.id, + }); + assert.equal(refreshedResponse.status, 200); + const refreshed = ProcessResponseSchema.parse(await refreshedResponse.json()).process; + assert.equal(refreshed.values.tasks.length, resolved.values.tasks.length + 1); + assert.equal(refreshed.history.length, 3); + assert.ok(refreshed.values.tasks.slice(0, -1).every((t) => t.status === "done")); + assert.equal( + (await request(`${path}/${process.id}/refresh`, { revisionId: resolved.history.at(-1)?.id })) + .status, + 409 + ); + const freshTasks = refreshed.values.tasks.map((t) => ({ + id: t.id, + status: "not_applicable" as const, + notes: "Transferido o comprobado", + })); + const finished = await save({ + ...edit, + revisionId: refreshed.history.at(-1)?.id ?? "", + state: "completed", + tasks: freshTasks, + }); + assert.equal(finished.values.state, "completed"); + assert.equal(finished.history.length, 4); + assert.ok(finished.review.some((r) => r.kind === "remote")); // Completar seguimiento no revoca una conexión real. + await error( + { + ...edit, + revisionId: finished.history.at(-1)?.id, + notes: "Cambiar cerrado", + tasks: freshTasks, + }, + "PROCESS_CLOSED", + process.id + ); + assert.equal( + (await request(`${path}/${process.id}/refresh`, { revisionId: finished.history.at(-1)?.id })) + .status, + 409 + ); + await error(input, "PROCESS_TEMPLATE_INACTIVE"); + assert.equal( + (await request(`/processes/templates/${template.id}/update`, { values: templateValues })) + .status, + 200 + ); + const restarted = await request(path, { values: input }); + assert.equal(restarted.status, 201); + const next = ProcessResponseSchema.parse(await restarted.json()).process; + assert.notEqual(next.id, process.id); + assert.equal((await request("/processes/templates", { values: templateValues })).status, 409); + const onboarding = templates.find((t) => t.values.kind === "onboarding"); + assert.ok(onboarding); + const onboardResponse = await request(path, { + values: { ...input, templateId: onboarding.id, name: "Alta test" }, + }); + assert.equal(onboardResponse.status, 201); + const onboard = ProcessResponseSchema.parse(await onboardResponse.json()).process; + assert.ok(onboard.values.tasks.every((t) => !t.reference)); + await database.query("UPDATE person_processes SET currentKey=:key WHERE id=:id", { + replacements: { key: "bad", id: next.id }, + }); + try { + const corrupt = await request(path); + assert.equal(corrupt.status, 500); + assert.ok(!(await corrupt.text()).includes("SELECT")); + } finally { + await database.query("UPDATE person_processes SET currentKey=:key WHERE id=:id", { + replacements: { key: `${companyId}:${person.id}:offboarding`, id: next.id }, + }); + } + assert.equal((await request(path)).status, 200); + assert.equal( + (await request(`/assets/${companyId}/${host.id}/update`, { values: host.values })).status, + 200 + ); +} diff --git a/docs/accesos-remotos.md b/docs/accesos-remotos.md index d77d88c..7c9ea47 100644 --- a/docs/accesos-remotos.md +++ b/docs/accesos-remotos.md @@ -59,3 +59,5 @@ Diez pruebas nuevas de dominio/interfaz y cobertura HTTP con MariaDB exclusivame Suite completa: 142 pruebas correctas. Typecheck y build correctos. Lint y check mantienen respectivamente 32 y 90 errores previos, dos avisos y tres informativos; el bloque nuevo no añade incidencias. Navegador: listado, formulario, cambio de origen personal, destinos, herramienta libre, altas rápidas y cancelación conservando el padre, ayuda inferior y gestión desde Entorno actual. No se guardan registros ficticios de desarrollo. + +Los [procesos de cambio y salida](procesos-de-personas.md) incorporan revisiones de accesos remotos abiertos, incluidos los de equipos personales. Resolver el seguimiento no revoca la conexión ni altera el historial del acceso. diff --git a/docs/aplicaciones-del-perfil.md b/docs/aplicaciones-del-perfil.md index 06742df..ca69d30 100644 --- a/docs/aplicaciones-del-perfil.md +++ b/docs/aplicaciones-del-perfil.md @@ -2,7 +2,7 @@ ## Uso -Aplicaciones del perfil está disponible en la compañía y desde cada perfil de trabajo. El listado permite buscar por aplicación, perfil, persona y equipo. El alta y la edición se abren mediante modal; la ayuda aparece inmediatamente antes de Guardar y Cancelar. +Aplicaciones del perfil está disponible en la compañía y desde cada perfil de usuario. El listado permite buscar por aplicación, perfil, persona y equipo. El alta y la edición se abren mediante modal; la ayuda aparece inmediatamente antes de Guardar y Cancelar. La aplicación es un Resource de la compañía. Se seleccionan recursos de la plantilla de serie Aplicación o de una plantilla personalizada con uso Aplicación. El nombre no determina el tipo. Las copias de las plantillas de aplicación y certificado heredan su uso; las plantillas nuevas permiten elegirlo. El uso queda fijo tras el alta, igual que el tipo de un campo. Los recursos existentes y sus especificaciones no se reescriben. diff --git a/docs/arquitectura/modelo-ddd-e-identificadores.md b/docs/arquitectura/modelo-ddd-e-identificadores.md index 503b909..887711f 100644 --- a/docs/arquitectura/modelo-ddd-e-identificadores.md +++ b/docs/arquitectura/modelo-ddd-e-identificadores.md @@ -55,7 +55,7 @@ AssetRelationship es la raíz de un vínculo entre dos activos de una compañía Workstation es la raíz de cada puesto y WorkstationRevision una entidad con UUID propio para cada instantánea. WorkstationValues encapsula el nombre, estado, referencias opcionales y composición. Las referencias técnicas de reserva se derivan del agregado. Consultar [Puestos de trabajo](../puestos-de-trabajo.md). -WorkProfile es la raíz del perfil de una persona en un dispositivo; WorkProfileRevision conserva cada instantánea con UUID propio. WorkProfileValues encapsula Name, referencias UniqueID y opcionales Maybe. Consultar [Perfiles de trabajo](../perfiles-de-trabajo.md). +WorkProfile es la raíz del perfil de una persona en un dispositivo; WorkProfileRevision conserva cada instantánea con UUID propio. WorkProfileValues encapsula Name, referencias UniqueID y opcionales Maybe. Consultar [Perfiles de usuario](../perfiles-de-trabajo.md). Mailbox y MailboxUse son raíces de buzón y periodo de uso por persona. MailboxRevision y MailboxUseRevision son entidades internas con UUID propio. MailboxValues y MailboxUseValues encapsulan EmailAddress, referencias UniqueID, CalendarDate y opcionales Maybe. @@ -82,3 +82,7 @@ TeleworkAuthorization es AggregateRoot y TeleworkAuthorizationRevision es Domain ## Accesos remotos RemoteConnection es AggregateRoot; RemoteConnectionRevision es DomainEntity con UUID v7 propio. RemoteConnectionValues conserva referencias UniqueID, herramienta Name y opcionales Maybe. Origen personal se expresa por tipo, sin crear un activo. Recorrido fijo, estados de preparación/retirada, auditoría y unicidad de accesos abiertos; autorización y revisión derivadas del contexto. Consultar [Accesos remotos](../accesos-remotos.md). + +## Procesos de personas y plantillas + +ProcessTemplate y PersonProcess son AggregateRoot; sus revisiones y las tareas ProcessTask son DomainEntity con UniqueID v7. ProcessTemplateValues y ProcessValues encapsulan nombre Name, referencias UniqueID y opcionales Maybe; las referencias de tarea tienen también Value Object. Instrucciones de plantilla son valores sin identidad propia; cada tarea de ejecución tiene su UUID, preservado al resolverla. La ejecución conserva la plantilla inicial y su historial. Consultar [Procesos de personas](../procesos-de-personas.md). diff --git a/docs/correos-en-dispositivos.md b/docs/correos-en-dispositivos.md index 68bb0ec..3f9f3e7 100644 --- a/docs/correos-en-dispositivos.md +++ b/docs/correos-en-dispositivos.md @@ -14,7 +14,7 @@ Persona, buzón e inicio quedan fijos. Finalizar conserva el historial; un perio ## Configuración por perfil -«Correos en dispositivos» relaciona **perfil de trabajo + buzón**, método (POP, IMAP, Exchange u otro), estado y notas. El perfil identifica la persona y el equipo. No se solicita otra cuenta informática ni se vuelve a introducir la dirección. También se puede consultar y configurar el correo desde cada fila de «Perfiles de trabajo». +«Correos en dispositivos» relaciona **perfil de usuario + buzón**, método (POP, IMAP, Exchange u otro), estado y notas. El perfil identifica la persona y el equipo. No se solicita otra cuenta informática ni se vuelve a introducir la dirección. También se puede consultar y configurar el correo desde cada fila de «Perfiles de usuario». Por ejemplo, el perfil de Ana en su portátil puede tener agarcia@uecko.com por IMAP, administracion@pino.com por IMAP y pedidos@uecko.com por POP. Ese mismo buzón podría configurarse en su smartphone mediante otro perfil. El cliente de correo y otras necesidades del perfil se incorporarán en entregas posteriores. diff --git a/docs/entorno-actual.md b/docs/entorno-actual.md index a0e4e82..cf9f807 100644 --- a/docs/entorno-actual.md +++ b/docs/entorno-actual.md @@ -1,6 +1,6 @@ # Entorno actual -Ficha de consulta disponible desde «Entorno actual» en cada perfil de trabajo. Reúne la persona, el dispositivo, la cuenta de inicio de sesión y las configuraciones registradas para ese perfil. +Ficha de consulta disponible desde «Entorno actual» en cada perfil de usuario. Reúne la persona, el dispositivo, la cuenta de inicio de sesión y las configuraciones registradas para ese perfil. ## Equipo y puesto diff --git a/docs/impresoras-del-perfil.md b/docs/impresoras-del-perfil.md index 319982c..73083b7 100644 --- a/docs/impresoras-del-perfil.md +++ b/docs/impresoras-del-perfil.md @@ -2,7 +2,7 @@ ## Uso -Cada compañía dispone de Impresoras del perfil. También se accede desde la acción del mismo nombre en cada perfil de trabajo; esa consulta se filtra por perfil. Configurar impresora está a la derecha de la cabecera y abre un modal. No se muestran formularios junto al listado. +Cada compañía dispone de Impresoras del perfil. También se accede desde la acción del mismo nombre en cada perfil de usuario; esa consulta se filtra por perfil. Configurar impresora está a la derecha de la cabecera y abre un modal. No se muestran formularios junto al listado. Cada configuración vincula un perfil (persona y dispositivo) con una impresora del inventario de esa compañía. Se indica conexión USB o red, si es predeterminada, estado y notas opcionales. El usuario selecciona el activo que representa la impresora; no se infiere su función del nombre de una plantilla, porque las plantillas son configurables. El equipo anfitrión del perfil no puede seleccionarse como su propia impresora. diff --git a/docs/perfiles-de-trabajo.md b/docs/perfiles-de-trabajo.md index f58be4c..79f1ed7 100644 --- a/docs/perfiles-de-trabajo.md +++ b/docs/perfiles-de-trabajo.md @@ -1,8 +1,8 @@ -# Perfiles de trabajo +# Perfiles de usuario ## Uso y alcance -La sección Perfiles de trabajo de cada compañía registra el entorno de una persona en un dispositivo concreto. Nuevo perfil aparece a la derecha de la cabecera y abre un modal. Editar y Ficha e historial son acciones explícitas. Los perfiles archivados quedan ocultos inicialmente y pueden incluirse mediante una casilla. +La sección Perfiles de usuario de cada compañía registra el entorno de una persona en un dispositivo concreto. Nuevo perfil aparece a la derecha de la cabecera y abre un modal. Editar y Ficha e historial son acciones explícitas. Los perfiles archivados quedan ocultos inicialmente y pueden incluirse mediante una casilla. Nombre, persona y dispositivo son obligatorios. Cuenta de inicio de sesión y notas son opcionales. La persona no recibe acceso a Metis Desk. El dispositivo es un activo de la compañía; puede alojar perfiles de varias personas y una persona puede tener perfiles en varios dispositivos. No se deduce el tipo de dispositivo del nombre de la plantilla: las plantillas son configurables. La ayuda pide seleccionar un equipo que aloje la configuración. diff --git a/docs/personas-y-departamentos.md b/docs/personas-y-departamentos.md index 73949a0..4246d63 100644 --- a/docs/personas-y-departamentos.md +++ b/docs/personas-y-departamentos.md @@ -59,3 +59,5 @@ Implementado: personas, clasificación como contacto, departamentos, puestos esp Cuentas informáticas, grupos, recursos, autorizaciones y necesidades se gestionan en Cuentas y accesos de la compañía. No implican acceso de estas personas a Metis Desk. Los perfiles, sus configuraciones y el inventario de activos están implementados. La [autorización de teletrabajo](teletrabajo.md) también está disponible. Quedan pendientes las conexiones remotas y los procesos y plantillas de alta/cambio/salida. + +Los [procesos de personas](procesos-de-personas.md) organizan el alta, cambio y salida, conservando listas de comprobación, resultados e historial por compañía. Admiten personas inactivas para revisar accesos conservados; completar un proceso no desactiva ni elimina a la persona. diff --git a/docs/plan-de-desarrollo.md b/docs/plan-de-desarrollo.md index 1c15020..9e55c08 100644 --- a/docs/plan-de-desarrollo.md +++ b/docs/plan-de-desarrollo.md @@ -39,9 +39,9 @@ Catálogos globales con adiciones por compañía, entornos, cuentas con uso fijo Catálogo global con 30 campos y 12 plantillas de serie protegidas, cinco tipos de dato, copias personalizables, adiciones por compañía y ajustes locales de disponibilidad. Características opcionales por defecto, modales con ayuda y alta rápida de campos desde las plantillas. Consultar [Campos y plantillas de activos](campos-y-plantillas-de-activos.md). -## Perfiles de trabajo: base implementada +## Perfiles de usuario: base implementada -Persona y dispositivo fijos, cuenta de inicio de sesión opcional, perfiles vigentes y archivados, múltiples personas/dispositivos, historial y avisos de revisión. Consultar [Perfiles de trabajo](perfiles-de-trabajo.md). La ficha Entorno actual reúne las configuraciones implementadas; el mapa visual queda aplazado. +Persona y dispositivo fijos, cuenta de inicio de sesión opcional, perfiles vigentes y archivados, múltiples personas/dispositivos, historial y avisos de revisión. Consultar [Perfiles de usuario](perfiles-de-trabajo.md). La ficha Entorno actual reúne las configuraciones implementadas; el mapa visual queda aplazado. ## Buzones y correo por perfil: implementado @@ -75,10 +75,14 @@ Autorización por persona, fechas opcionales inclusivas, revocación y renovaci Origen corporativo o personal sin inventario detallado, herramienta libre, destino red/sede/equipo/recurso, requisito VPN, preparación y retirada, historial y revisión según autorización y disponibilidad. Consulta desde Entorno actual. Documenta accesos; no actúa sobre conexiones reales. Consultar [Accesos remotos](accesos-remotos.md). +## Procesos de personas: implementado + +Plantillas globales de alta/cambio/salida, copias de instrucciones por ejecución, seguimiento por persona con tareas UUID, resultados, cierres, historial y revisión de registros actuales. Cambio y salida incorporan revisiones de cuentas, configuraciones, activos asignados y accesos remotos personales. Modales, altas rápidas y protección frente a ediciones concurrentes. Consultar [Procesos de personas](procesos-de-personas.md). + ## Siguientes entregas: orden actualizado -1. Procesos de alta, cambio y salida con seguimiento de configuraciones y retiradas. Plantillas reutilizables entre compañías; ejecuciones y datos propios. -2. Asistencia técnica remota independiente de los accesos de las personas. Vinculación explícita entre un acceso y su VPN previa si resulta necesaria. +1. Asistencia técnica remota independiente de los accesos de las personas. Vinculación explícita entre un acceso y su VPN previa si resulta necesaria. +2. Tickets e intervenciones con técnico, tiempos y elementos afectados. Este orden se concreta antes de cada entrega. Las licencias y su relación con cuentas se explorarán posteriormente. diff --git a/docs/procesos-de-personas.md b/docs/procesos-de-personas.md new file mode 100644 index 0000000..14ca7e7 --- /dev/null +++ b/docs/procesos-de-personas.md @@ -0,0 +1,92 @@ +# Procesos de alta, cambio y salida + +Listas de comprobación para organizar el trabajo de los técnicos. Las plantillas son globales y las ejecuciones pertenecen a una compañía y persona. Las personas no tienen acceso a Metis Desk. + +## Plantillas comunes + +Se incluyen tres plantillas listas para usar: Alta de persona, Cambio de equipo o funciones y Salida de persona. Contienen instrucciones generales, sin datos de compañías ni personas. + +Campos: nombre, tipo (alta/cambio/salida), activa, notas opcionales e instrucciones, una tarea por línea. Entre 1 y 100 instrucciones de hasta 160 caracteres, sin duplicados. Nombre único global sin distinguir mayúsculas; su clave técnica se calcula con SHA-256 para no depender de la colación de MariaDB. + +Se pueden crear, editar, copiar y desactivar plantillas. Desactivar impide nuevos procesos, sin afectar a los existentes. No hay borrado ni personalización por compañía en esta primera versión: una variante se crea como otra plantilla global. + +Editar una plantilla afecta únicamente a futuras ejecuciones. Cada ejecución conserva el nombre, tipo e instrucciones iniciales de su plantilla. Las notas del proceso son propias de la ejecución. Las plantillas y sus revisiones tienen UUID v7; las revisiones iniciales de serie se atribuyen al sistema. + +## Ejecución por persona + +Crear pide nombre, persona y plantilla, con notas opcionales. Se admiten personas inactivas para documentar una salida o revisar configuraciones conservadas. La persona y plantilla inicial no se cambian. + +Solo puede existir un proceso En curso de cada tipo para una persona de una compañía. Tipos diferentes pueden coexistir. Una vez cerrado, se puede iniciar otro del mismo tipo. + +Tareas con identidad UUID propia, estado Pendiente, Revisada o No procede, y resultado/notas opcionales. Revisada significa que el técnico ha comprobado la tarea; documenta si el elemento se retira, conserva o reasigna. No representa una revocación automática ni una comprobación técnica del equipo. + +El proceso pasa de En curso a Completado o Cancelado. Completado exige todas las tareas Revisadas o No procede. Los cerrados se consultan y conservan su historial; no se reabren ni editan. Una repetición idéntica es idempotente, sin nueva revisión. + +Cada actualización conserva actor autenticado y fecha UTC. La revisión esperada del formulario evita sobreescribir cambios de otro técnico. Ante conflicto, cancelar, recargar y volver a editar. + +## Revisión de registros existentes + +Cambio y Salida añaden automáticamente tareas para revisar los registros documentados de esa persona: + +- Activos disponibles asignados directamente a la persona. +- Perfiles vigentes, relaciones con cuentas y pertenencias a grupos. +- Necesidades de recursos y periodos de uso de correo. +- Configuraciones abiertas de correo, carpetas, impresoras, aplicaciones y certificados, incluso en perfiles archivados. +- Autorizaciones de teletrabajo conservadas activas y accesos remotos abiertos, incluidos los procedentes de ordenador o móvil personal. + +Las referencias se obtienen en el servidor, dentro del contexto de compañía. El inventario de activos y los nombres de recursos se consultan mediante sus servicios públicos; no se leen tablas de otro módulo. No se copian contraseñas. + +Se incluyen relaciones no finalizadas (también programadas) y aquellas que, aun finalizadas, siguen figurando configuradas o pendientes de retirada. Se excluyen configuraciones Retiradas/Nunca configuradas y se evita duplicar una misma referencia. La lista es documental: no calcula de nuevo todos los permisos efectivos ni garantiza que una conexión real esté abierta o cerrada. + +Alta utiliza únicamente las instrucciones de la plantilla; crear un proceso no crea cuentas, dispositivos ni configuraciones. + +La ficha contrasta las tareas con la revisión actual obtenida de nuevo del servidor. Una tarea resuelta puede seguir figurando en la revisión: por ejemplo, se ha decidido conservar temporalmente una cuenta compartida. La ausencia de una referencia tampoco certifica una retirada física o técnica. + +«Añadir nuevas revisiones» incorpora referencias nuevas sin borrar ni cambiar los resultados anteriores. No duplica tareas ni vuelve a dejar pendientes las ya revisadas. Una misma referencia que reaparece conserva su tarea; se revisa nuevamente editando su seguimiento. + +Los activos presentes solo a través de la composición de un puesto, la transferencia de datos y las comprobaciones no representadas en los registros anteriores se revisan mediante las instrucciones generales. Las cuentas compartidas y grupos se comprueban sin desactivar a otros usuarios automáticamente. + +No se desactiva a la persona, archiva el perfil, revoca la autorización, retira un acceso ni reasigna un activo al completar o cancelar. Esas operaciones se realizan en sus gestiones respectivas. + +## Interfaz + +Sección Procesos de personas en el espacio de compañía, con búsqueda, consulta de cerrados, progreso y botón Nuevo proceso en la cabecera derecha. Plantillas de procesos abre el catálogo común desde la misma sección. + +La ficha del proceso es de consulta: tareas, resultados, revisión actual e historial. Actualizar seguimiento abre un modal; los listados no contienen formularios visibles. Alta, edición de plantilla y seguimiento utilizan modales shadcn/ui, ayuda inferior y pie Guardar/Cancelar. + +Persona y plantilla permiten SelectWithAction y alta rápida en un único formulario hijo. Cancelar el hijo conserva el padre. Referencias guardadas permanecen si se cancela el proceso. Tipos y estados son fijos, sin altas arbitrarias. + +Traducciones es/en, carga, errores dentro del formulario y reintento. Las plantillas de serie son datos de catálogo en castellano; sus textos se pueden personalizar. Los nombres de dispositivos y recursos no se traducen. + +## Dominio y persistencia + +ProcessTemplate y PersonProcess son AggregateRoot. ProcessTemplateRevision, PersonProcessRevision y ProcessTask son DomainEntity con UniqueID. ProcessTemplateValues y ProcessValues son Value Objects; las tareas contienen Name, Maybe y referencias encapsuladas con UniqueID y Name. + +Create genera UUID v7; update conserva identidades y compara semánticamente. Rehydrate reconstruye sin generar entidades nuevas ni eventos. Instrucciones de plantilla son valores; las tareas de ejecución tienen identidad independiente. Revisiones capturan sus identidades y resultados. + +Migraciones explícitas e idempotentes: process_templates, process_template_revisions, person_processes y person_process_revisions. process_catalog_lock es un registro técnico para serializar cambios globales. FK a compañía, persona, plantilla y actor, e índices de unicidad de procesos abiertos y posiciones de revisión. Las referencias documentales a elementos revisados son capturas tipadas; no permiten modificar esos elementos. + +Escrituras atómicas con bloqueo de catálogo o compañía. Mappers bidireccionales verifican claves, ámbito, UUID, historial completo, posiciones, fechas, actor, igualdad raíz/última revisión, identidad de tareas, ampliaciones válidas y cierres. Una corrupción falla sin exponer SQL. + +## HTTP + +Rutas con sesión y protección de origen/CSRF en escrituras; actor obtenido de sesión, nunca del formulario. Contratos Zod estrictos y UUID normalizados. + +- GET /api/processes/templates y POST en la misma ruta. +- POST /api/processes/templates/:id/update. +- GET /api/processes/:companyId y POST para iniciar. +- GET /api/processes/:companyId/people/:id/review. +- POST /api/processes/:companyId/:id/update con revisión esperada. +- POST /api/processes/:companyId/:id/refresh con revisión esperada. + +No hay borrado. Consultar por otra compañía no revela la ejecución ni la revisión de la persona. + +## Validación + +Doce casos nuevos de dominio e interfaz: contratos, límites, UUID, identidad de tareas, igualdad e idempotencia, cierre y pendientes, plantillas independientes, ampliación sin duplicados, reconstrucción y corrupción, revisión de configuraciones conservadas, traducciones, ayuda, altas rápidas y distinción entre seguimiento y registros actuales. + +Integración HTTP/MariaDB exclusivamente en metis_desk_test: semillas, creación, concurrencia, aislamiento, origen/CSRF, sesión, actores, activos asignados, configuraciones, accesos desde móvil personal, copia de plantilla, desactivación, conflictos, ampliación, finalización, proceso nuevo tras cierre y fallo cerrado ante corrupción. Reiniciar la suite y el runtime conserva semillas y aplica migraciones idempotentes. + +Validación final: 154 pruebas correctas en el monorepo; typecheck y build correctos. Lint y check mantienen 32 y 90 errores previos, dos avisos y tres informativos. El código nuevo pasa Biome sin incidencias. + +Navegador: sección, listado sin formularios, selección de persona/plantilla, contador de revisión real, altas rápidas y ayuda inferior. No se guardan procesos ni personas ficticias en desarrollo. diff --git a/docs/puestos-de-trabajo.md b/docs/puestos-de-trabajo.md index e0e4fd0..4911aa1 100644 --- a/docs/puestos-de-trabajo.md +++ b/docs/puestos-de-trabajo.md @@ -32,6 +32,6 @@ GET /api/assets/:companyId incluye workstations junto a assets y relationships. Las pruebas verifican identidad, composición opcional, límites, igualdad, historial, ámbito, ausencia de anidación, desactivación y reactivación, personas inactivas, concurrencia y persistencia. La comprobación de navegador incluye altas rápidas, cancelación, composición, activos ocupados e historial. -## Perfiles de trabajo +## Perfiles de usuario -Un puesto agrupa equipos y recursos; el [perfil de trabajo](perfiles-de-trabajo.md) registra la persona en un dispositivo concreto. La composición no crea conexiones físicas, permisos ni perfiles automáticamente. La asignación de un activo no representa propiedad personal: todos los activos del inventario pertenecen a su compañía. +Un puesto agrupa equipos y recursos; el [perfil de usuario](perfiles-de-trabajo.md) registra la persona en un dispositivo concreto. La composición no crea conexiones físicas, permisos ni perfiles automáticamente. La asignación de un activo no representa propiedad personal: todos los activos del inventario pertenecen a su compañía. diff --git a/modules/assets/src/api/module.ts b/modules/assets/src/api/module.ts index b6a1531..25073a8 100644 --- a/modules/assets/src/api/module.ts +++ b/modules/assets/src/api/module.ts @@ -56,6 +56,8 @@ export async function createAssetModule(database: Sequelize, authenticate: Reque } return result.data.assets.map((asset) => ({ id: asset.id, + name: asset.values.toPrimitive().name, + personId: asset.values.toPrimitive().personId, available: asset.values.toPrimitive().status !== "retired", })); }, diff --git a/modules/companies/package.json b/modules/companies/package.json index 07becee..b6ae402 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/web/remote/remote.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" }, "dependencies": { "@repo/rdx-ddd": "workspace:*", diff --git a/modules/companies/src/api/application/process-response.builder.ts b/modules/companies/src/api/application/process-response.builder.ts new file mode 100644 index 0000000..59d3c7f --- /dev/null +++ b/modules/companies/src/api/application/process-response.builder.ts @@ -0,0 +1,39 @@ +import type { PersonProcessDTO, ProcessTemplateDTO } from "../../common/processes"; +import type { PersonProcess } from "../domain/process.aggregate"; +import type { ProcessTemplate } from "../domain/process-template.aggregate"; + +import type { ProcessCatalogContext, ProcessContext } from "./process.repository"; +export function buildProcessTemplate( + template: ProcessTemplate, + context: ProcessCatalogContext +): ProcessTemplateDTO { + return { + id: template.id.toPrimitive(), + values: template.values.toPrimitive(), + history: template.history.map((r) => ({ + id: r.id.toPrimitive(), + values: r.values.toPrimitive(), + actorId: r.actorId.getOrUndefined()?.toPrimitive() ?? null, + actorName: r.actorId.getOrUndefined() + ? (context.actors.get(r.actorId.getOrUndefined()?.toPrimitive() ?? "") ?? "Usuario") + : "Metis Desk", + recordedAt: r.recordedAt, + })), + }; +} +export function buildProcess(process: PersonProcess, context: ProcessContext): PersonProcessDTO { + return { + id: process.id.toPrimitive(), + companyId: process.companyId.toPrimitive(), + values: process.values.toPrimitive(), + review: [...(context.reviews.get(process.values.personId.toPrimitive()) ?? [])], + history: process.history.map((r) => ({ + id: r.id.toPrimitive(), + values: r.values.toPrimitive(), + actorId: r.actorId.getOrUndefined()?.toPrimitive() ?? null, + actorName: + context.actors.get(r.actorId.getOrUndefined()?.toPrimitive() ?? "") ?? "Metis Desk", + recordedAt: r.recordedAt, + })), + }; +} diff --git a/modules/companies/src/api/application/process-review-access.ts b/modules/companies/src/api/application/process-review-access.ts new file mode 100644 index 0000000..81d4ac1 --- /dev/null +++ b/modules/companies/src/api/application/process-review-access.ts @@ -0,0 +1,72 @@ +import type { AccessDefinition } from "../domain/access/access-definition"; + +import type { ProcessReviewSources } from "./process-review.builder"; + +type Item = { id: string; values: AccessDefinition }; +type Relationship = ProcessReviewSources["relationships"][number]; +function memberships( + link: Extract, + login: string, + items: readonly Item[] +): Relationship[] { + return items.flatMap((item) => { + const m = item.values; + if (m.kind !== "membership" || m.accountId !== link.accountId) { + return []; + } + const group = items.find((i) => i.id === m.groupId)?.values; + if (group?.kind !== "group") { + throw new Error("Grupo de revisión inconsistente."); + } + const ends = [link.endDate, m.endDate].filter((date): date is string => Boolean(date)).sort(); + return [ + { + id: item.id, + kind: "membership", + personId: link.personId, + label: `${login} · ${group.name}`, + endDate: ends[0] ?? null, + configuration: m.configuration, + }, + ]; + }); +} +export function accessReviewRelationships(items: readonly Item[]): Relationship[] { + return items.flatMap((item) => { + const v = item.values; + if (v.kind === "account_person") { + const account = items.find((i) => i.id === v.accountId)?.values; + if (account?.kind !== "account") { + throw new Error("Cuenta de revisión inconsistente."); + } + return [ + { + id: item.id, + kind: "account", + personId: v.personId, + label: account.login, + endDate: v.endDate, + configuration: v.configuration, + }, + ...memberships(v, account.login, items), + ]; + } + if (v.kind === "need") { + const resource = items.find((i) => i.id === v.resourceId)?.values; + if (resource?.kind !== "resource") { + throw new Error("Recurso de revisión inconsistente."); + } + return [ + { + id: item.id, + kind: "need", + personId: v.personId, + label: resource.name, + endDate: v.endDate, + configuration: null, + }, + ]; + } + return []; + }); +} diff --git a/modules/companies/src/api/application/process-review.builder.ts b/modules/companies/src/api/application/process-review.builder.ts new file mode 100644 index 0000000..b6bceb5 --- /dev/null +++ b/modules/companies/src/api/application/process-review.builder.ts @@ -0,0 +1,64 @@ +import type { ProcessReferenceInput } from "../domain/process-values"; +export type ProcessReviewSources = { + assets: readonly { id: string; name: string; personId: string | null; available: boolean }[]; + profiles: readonly { id: string; personId: string; name: string; active: boolean }[]; + configurations: readonly { + id: string; + personId: string; + kind: ProcessReferenceInput["kind"]; + label: string; + state: string; + sourceKind?: ProcessReferenceInput["sourceKind"]; + }[]; + relationships: readonly { + id: string; + personId: string; + kind: ProcessReferenceInput["kind"]; + label: string; + endDate: string | null; + configuration: string | null; + }[]; + authorizations: readonly { id: string; personId: string; active: boolean }[]; +}; +export function buildProcessReview( + personId: string, + sources: ProcessReviewSources, + today: string +): ProcessReferenceInput[] { + const closed = (state: string | null) => state === "removed" || state === "never_configured"; + const result: ProcessReferenceInput[] = sources.assets + .filter((a) => a.personId === personId && a.available) + .map((a) => ({ kind: "asset", id: a.id, label: a.name })); + for (const p of sources.profiles) { + if (p.personId === personId && p.active) { + result.push({ kind: "profile", id: p.id, label: p.name }); + } + } + for (const c of sources.configurations) { + if (c.personId === personId && !closed(c.state)) { + result.push({ + kind: c.kind, + id: c.id, + label: c.label, + ...(c.sourceKind ? { sourceKind: c.sourceKind } : {}), + }); + } + } + const relevantRelationships = sources.relationships + .filter((r) => r.personId === personId) + .filter( + (r) => + !r.endDate || r.endDate >= today || Boolean(r.configuration && !closed(r.configuration)) + ); + result.push(...relevantRelationships.map((r) => ({ kind: r.kind, id: r.id, label: r.label }))); + for (const a of sources.authorizations) { + if (a.personId === personId && a.active) { + result.push({ kind: "telework", id: a.id, label: personId }); + } + } + return [ + ...new Map( + result.map((ref) => [`${ref.kind}:${ref.id}`, { ...ref, label: ref.label.slice(0, 500) }]) + ).values(), + ]; +} diff --git a/modules/companies/src/api/application/process.repository.ts b/modules/companies/src/api/application/process.repository.ts new file mode 100644 index 0000000..e469a11 --- /dev/null +++ b/modules/companies/src/api/application/process.repository.ts @@ -0,0 +1,34 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import type { Result } from "@repo/rdx-utils"; + +import type { PersonProcess } from "../domain/process.aggregate"; +import type { ProcessTemplate } from "../domain/process-template.aggregate"; +import type { ProcessError, ProcessReferenceInput } from "../domain/process-values"; +export type ProcessCatalogContext = { + templates: readonly ProcessTemplate[]; + actors: ReadonlyMap; +}; +export type ProcessContext = ProcessCatalogContext & { + processes: readonly PersonProcess[]; + people: readonly UniqueID[]; + reviews: ReadonlyMap; +}; +export interface IProcessRepository { + catalog(): Promise>; + saveTemplate( + action: (context: ProcessCatalogContext) => Result + ): Promise>; + get(company: UniqueID): Promise>; + save( + company: UniqueID, + action: (context: ProcessContext) => Result + ): Promise>; +} + +export interface IProcessAssetInventoryService { + list( + company: UniqueID + ): Promise< + readonly { id: UniqueID; name: string; personId: string | null; available: boolean }[] + >; +} diff --git a/modules/companies/src/api/application/process.use-cases.ts b/modules/companies/src/api/application/process.use-cases.ts new file mode 100644 index 0000000..1e6617d --- /dev/null +++ b/modules/companies/src/api/application/process.use-cases.ts @@ -0,0 +1,175 @@ +import { UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { PersonProcess } from "../domain/process.aggregate"; +import { ProcessTask } from "../domain/process-task.entity"; +import { ProcessTemplate } from "../domain/process-template.aggregate"; +import { + ProcessError, + type ProcessTemplateInput, + type StartProcessInput, + type UpdateProcessInput, +} from "../domain/process-values"; +import { ProcessTemplateValues } from "../domain/value-objects/process-template-values"; +import { ProcessValues } from "../domain/value-objects/process-values"; + +import type { IProcessRepository } from "./process.repository"; +export class ProcessUseCases { + constructor(private readonly repository: IProcessRepository) {} + catalog() { + return this.repository.catalog(); + } + get(companyId: string) { + const company = UniqueID.create(companyId); + return company.isSuccess + ? this.repository.get(company.data) + : Promise.resolve(Result.fail(new ProcessError("INVALID_REQUEST"))); + } + saveTemplate(input: ProcessTemplateInput, actorId: string, id?: string) { + const actor = UniqueID.create(actorId); + const values = ProcessTemplateValues.create(input); + const identity = id ? UniqueID.create(id) : undefined; + if (!(actor.isSuccess && values.isSuccess) || (identity && !identity.isSuccess)) { + return Promise.resolve(Result.fail(new ProcessError("INVALID_REQUEST"))); + } + return this.repository.saveTemplate((context) => { + const previous = identity?.isSuccess + ? context.templates.find((t) => t.id.equals(identity.data)) + : undefined; + if (id && !previous) { + return Result.fail(new ProcessError("PROCESS_TEMPLATE_NOT_FOUND")); + } + if ( + context.templates.some( + (t) => + !t.id.equals(previous?.id) && + t.values.name.toPrimitive().toLowerCase() === + values.data.name.toPrimitive().toLowerCase() + ) + ) { + return Result.fail(new ProcessError("PROCESS_TEMPLATE_DUPLICATE")); + } + const now = new Date().toISOString(); + return previous + ? previous.update(values.data, actor.data, now) + : ProcessTemplate.create(values.data, Maybe.some(actor.data), now); + }); + } + start(companyId: string, input: StartProcessInput, actorId: string) { + const company = UniqueID.create(companyId); + const actor = UniqueID.create(actorId); + if (!(company.isSuccess && actor.isSuccess)) { + return Promise.resolve(Result.fail(new ProcessError("INVALID_REQUEST"))); + } + return this.repository.save(company.data, (context) => { + const template = context.templates.find((t) => t.id.toPrimitive() === input.templateId); + if (!template) { + return Result.fail(new ProcessError("PROCESS_TEMPLATE_NOT_FOUND")); + } + if (!template.values.active) { + return Result.fail(new ProcessError("PROCESS_TEMPLATE_INACTIVE")); + } + if (!context.people.some((p) => p.toPrimitive() === input.personId)) { + return Result.fail(new ProcessError("PROCESS_PERSON_NOT_FOUND")); + } + if ( + context.processes.some( + (p) => + p.values.personId.toPrimitive() === input.personId && + p.values.kind === template.values.kind && + !p.values.closed + ) + ) { + return Result.fail(new ProcessError("PROCESS_ALREADY_OPEN")); + } + const refs = + template.values.kind === "onboarding" ? [] : (context.reviews.get(input.personId) ?? []); + const tasks = [ + ...template.values.instructions.map((i) => ProcessTask.create(i.toPrimitive())), + ...refs.map((ref) => ProcessTask.create(ref.label.slice(0, 160), ref)), + ]; + const values = ProcessValues.create({ + ...input, + templateName: template.values.name.toPrimitive(), + kind: template.values.kind, + state: "open", + tasks: tasks.map((t) => t.toPrimitive()), + }); + return values.isSuccess + ? PersonProcess.create( + company.data, + values.data, + Maybe.some(actor.data), + new Date().toISOString() + ) + : values; + }); + } + update(companyId: string, id: string, input: UpdateProcessInput, actorId: string) { + const company = UniqueID.create(companyId); + const actor = UniqueID.create(actorId); + if (!(company.isSuccess && actor.isSuccess)) { + return Promise.resolve(Result.fail(new ProcessError("INVALID_REQUEST"))); + } + return this.repository.save(company.data, (context) => { + const previous = context.processes.find((p) => p.id.toPrimitive() === id); + if (!previous) { + return Result.fail(new ProcessError("PROCESS_NOT_FOUND")); + } + if ( + input.tasks.length !== previous.values.tasks.length || + new Set(input.tasks.map((t) => t.id)).size !== input.tasks.length + ) { + return Result.fail(new ProcessError("PROCESS_TASKS_IMMUTABLE")); + } + const tasks = previous.values.tasks.map((task) => { + const value = task.toPrimitive(); + const change = input.tasks.find((t) => t.id === value.id); + return change ? { ...value, status: change.status, notes: change.notes } : value; + }); + if ( + input.tasks.some((t) => !previous.values.tasks.some((p) => p.id.toPrimitive() === t.id)) + ) { + return Result.fail(new ProcessError("PROCESS_TASKS_IMMUTABLE")); + } + const values = ProcessValues.create({ + ...previous.values.toPrimitive(), + name: input.name, + notes: input.notes, + state: input.state, + tasks, + }); + if (!values.isSuccess) { + return values; + } + if ( + !previous.values.equals(values.data) && + previous.history.at(-1)?.id.toPrimitive() !== input.revisionId + ) { + return Result.fail(new ProcessError("PROCESS_CONFLICT")); + } + return previous.update(values.data, actor.data, new Date().toISOString()); + }); + } + refresh(companyId: string, id: string, revisionId: string, actorId: string) { + const company = UniqueID.create(companyId); + const actor = UniqueID.create(actorId); + if (!(company.isSuccess && actor.isSuccess)) { + return Promise.resolve(Result.fail(new ProcessError("INVALID_REQUEST"))); + } + return this.repository.save(company.data, (context) => { + const previous = context.processes.find((p) => p.id.toPrimitive() === id); + if (!previous) { + return Result.fail(new ProcessError("PROCESS_NOT_FOUND")); + } + if (previous.history.at(-1)?.id.toPrimitive() !== revisionId) { + return Result.fail(new ProcessError("PROCESS_CONFLICT")); + } + return previous.appendReviews( + context.reviews.get(previous.values.personId.toPrimitive()) ?? [], + actor.data, + new Date().toISOString() + ); + }); + } +} diff --git a/modules/companies/src/api/domain/process-revision.entity.ts b/modules/companies/src/api/domain/process-revision.entity.ts new file mode 100644 index 0000000..e413990 --- /dev/null +++ b/modules/companies/src/api/domain/process-revision.entity.ts @@ -0,0 +1,23 @@ +import { DomainEntity, type UniqueID } from "@repo/rdx-ddd"; +import type { Maybe } from "@repo/rdx-utils"; + +import type { ProcessValues } from "./value-objects/process-values"; + +type Props = Readonly<{ values: ProcessValues; actorId: Maybe; recordedAt: string }>; +export class PersonProcessRevision extends DomainEntity { + static create(props: Props) { + return new PersonProcessRevision(Object.freeze({ ...props })); + } + static rehydrate(props: Props, id: UniqueID) { + return new PersonProcessRevision(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/process-task.entity.ts b/modules/companies/src/api/domain/process-task.entity.ts new file mode 100644 index 0000000..9907b86 --- /dev/null +++ b/modules/companies/src/api/domain/process-task.entity.ts @@ -0,0 +1,109 @@ +import { DomainEntity, Name, UniqueID } from "@repo/rdx-ddd"; +import { Maybe } from "@repo/rdx-utils"; + +import { + type ProcessReferenceInput, + ProcessReferenceSchema, + type ProcessTaskInput, + ProcessTaskSchema, +} from "./process-values"; + +class ProcessReferenceValues { + private constructor( + public readonly id: UniqueID, + public readonly label: Name, + public readonly kind: ProcessReferenceInput["kind"], + public readonly sourceKind: Maybe + ) { + Object.freeze(this); + } + static create(input: ProcessReferenceInput) { + const value = ProcessReferenceSchema.parse(input); + const id = UniqueID.create(value.id); + const label = Name.create(value.label, 500); + if (!(id.isSuccess && label.isSuccess)) { + throw new Error("Referencia de tarea inconsistente."); + } + return new ProcessReferenceValues( + id.data, + label.data, + value.kind, + value.sourceKind ? Maybe.some(value.sourceKind) : Maybe.none() + ); + } + toPrimitive(): ProcessReferenceInput { + const sourceKind = this.sourceKind.getOrUndefined(); + return { + kind: this.kind, + id: this.id.toPrimitive(), + label: this.label.toPrimitive(), + ...(sourceKind ? { sourceKind } : {}), + }; + } + equals(other: ProcessReferenceValues) { + return ( + this.id.equals(other.id) && + this.label.equals(other.label) && + this.kind === other.kind && + this.sourceKind.getOrUndefined() === other.sourceKind.getOrUndefined() + ); + } +} +type Values = { + title: Name; + status: ProcessTaskInput["status"]; + notes: Maybe; + reference: Maybe; +}; +function mapped(value: Omit): Values { + const title = Name.create(value.title, 160); + if (!title.isSuccess) { + throw new Error("Título de tarea inconsistente."); + } + return { + title: title.data, + status: value.status, + notes: value.notes ? Maybe.some(value.notes) : Maybe.none(), + reference: value.reference + ? Maybe.some(ProcessReferenceValues.create(value.reference)) + : Maybe.none(), + }; +} +export class ProcessTask extends DomainEntity { + static create(title: string, reference: ProcessReferenceInput | null = null) { + return new ProcessTask( + Object.freeze(mapped({ title, status: "pending", notes: null, reference })) + ); + } + static rehydrate(input: ProcessTaskInput) { + const value = ProcessTaskSchema.parse(input); + const id = UniqueID.create(value.id); + if (!id.isSuccess) { + throw new Error("Identidad de tarea inconsistente."); + } + return new ProcessTask(Object.freeze(mapped(value)), id.data); + } + toPrimitive(): ProcessTaskInput { + return { + id: this.id.toPrimitive(), + title: this.props.title.toPrimitive(), + status: this.props.status, + notes: this.props.notes.getOrUndefined() ?? null, + reference: this.props.reference.getOrUndefined()?.toPrimitive() ?? null, + }; + } + sameValues(other: ProcessTask) { + const a = this.props; + const b = other.props; + const ref = a.reference.getOrUndefined(); + const otherRef = b.reference.getOrUndefined(); + const referencesEqual = ref ? Boolean(otherRef && ref.equals(otherRef)) : !otherRef; + return ( + this.id.equals(other.id) && + a.title.equals(b.title) && + a.status === b.status && + a.notes.getOrUndefined() === b.notes.getOrUndefined() && + referencesEqual + ); + } +} diff --git a/modules/companies/src/api/domain/process-template-revision.entity.ts b/modules/companies/src/api/domain/process-template-revision.entity.ts new file mode 100644 index 0000000..b1d1116 --- /dev/null +++ b/modules/companies/src/api/domain/process-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 { ProcessTemplateValues } from "./value-objects/process-template-values"; + +type Props = Readonly<{ + values: ProcessTemplateValues; + actorId: Maybe; + recordedAt: string; +}>; +export class ProcessTemplateRevision extends DomainEntity { + static create(props: Props) { + return new ProcessTemplateRevision(Object.freeze({ ...props })); + } + static rehydrate(props: Props, id: UniqueID) { + return new ProcessTemplateRevision(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/process-template.aggregate.ts b/modules/companies/src/api/domain/process-template.aggregate.ts new file mode 100644 index 0000000..8c687b4 --- /dev/null +++ b/modules/companies/src/api/domain/process-template.aggregate.ts @@ -0,0 +1,70 @@ +import { AggregateRoot, type UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; +import { z } from "zod/v4"; + +import { ProcessTemplateRevision } from "./process-template-revision.entity"; +import { ProcessError } from "./process-values"; +import type { ProcessTemplateValues } from "./value-objects/process-template-values"; + +type Props = Readonly<{ + values: ProcessTemplateValues; + history: readonly ProcessTemplateRevision[]; +}>; +export class ProcessTemplate extends AggregateRoot { + private constructor(props: Props, id?: UniqueID) { + super(Object.freeze({ ...props, history: Object.freeze([...props.history]) }), id); + } + static create( + values: ProcessTemplateValues, + actor: Maybe, + recordedAt: string + ): Result { + if (!z.iso.datetime().safeParse(recordedAt).success) { + return Result.fail(new ProcessError("INVALID_REQUEST")); + } + return Result.ok( + new ProcessTemplate({ + values, + history: [ProcessTemplateRevision.create({ values, actorId: actor, recordedAt })], + }) + ); + } + static rehydrate(props: Props, id: UniqueID) { + return new ProcessTemplate(props, id); + } + + get values() { + return this.props.values; + } + get history() { + return this.props.history; + } + update( + values: ProcessTemplateValues, + actor: 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 ProcessError("INVALID_REQUEST")); + } + + return Result.ok( + new ProcessTemplate( + { + values, + history: [ + ...this.history, + ProcessTemplateRevision.create({ values, actorId: Maybe.some(actor), recordedAt }), + ], + }, + this.id + ) + ); + } +} diff --git a/modules/companies/src/api/domain/process-values.ts b/modules/companies/src/api/domain/process-values.ts new file mode 100644 index 0000000..2a69bff --- /dev/null +++ b/modules/companies/src/api/domain/process-values.ts @@ -0,0 +1,91 @@ +import { z } from "zod/v4"; +export const ProcessKindSchema = z.enum(["onboarding", "change", "offboarding"]); +const id = z.uuid().transform((value) => value.toLowerCase()); +const title = z.string().trim().min(1).max(160); +const notes = z + .string() + .trim() + .max(4000) + .nullable() + .transform((value) => value || null); +export const ProcessTemplateInputSchema = z + .strictObject({ + name: title, + kind: ProcessKindSchema, + active: z.boolean(), + notes, + instructions: z.array(title).min(1).max(100), + }) + .refine( + (v) => new Set(v.instructions.map((s) => s.toLowerCase())).size === v.instructions.length, + { path: ["instructions"], message: "INVALID_REQUEST" } + ); +export const ProcessReferenceSchema = z.strictObject({ + kind: z.enum([ + "profile", + "asset", + "account", + "membership", + "need", + "mailbox_use", + "mail", + "folder", + "printer", + "application", + "certificate", + "telework", + "remote", + ]), + id: id, + label: z.string().min(1).max(500), + sourceKind: z.enum(["company_asset", "personal_computer", "personal_mobile"]).optional(), +}); +export const ProcessTaskSchema = z.strictObject({ + id: id, + title, + status: z.enum(["pending", "done", "not_applicable"]), + notes, + reference: ProcessReferenceSchema.nullable(), +}); +export const ProcessValuesSchema = z + .strictObject({ + personId: id, + templateId: id, + templateName: title, + kind: ProcessKindSchema, + name: title, + state: z.enum(["open", "completed", "cancelled"]), + notes, + tasks: z.array(ProcessTaskSchema).min(1).max(2000), + }) + .refine((v) => new Set(v.tasks.map((s) => s.id)).size === v.tasks.length, { + message: "INVALID_REQUEST", + path: ["tasks"], + }); +export const StartProcessInputSchema = z.strictObject({ + personId: id, + templateId: id, + name: title, + notes, +}); +export const UpdateProcessInputSchema = z.strictObject({ + revisionId: id, + name: title, + notes, + state: ProcessValuesSchema.shape.state, + tasks: z + .array(ProcessTaskSchema.pick({ id: true, status: true, notes: true })) + .min(1) + .max(2000), +}); +export type ProcessTemplateInput = z.infer; +export type ProcessValuesInput = z.infer; +export type ProcessTaskInput = z.infer; +export type ProcessReferenceInput = z.infer; +export type StartProcessInput = z.infer; +export type UpdateProcessInput = z.infer; +export class ProcessError extends Error { + constructor(public readonly code: string) { + super(code); + } +} diff --git a/modules/companies/src/api/domain/process.aggregate.ts b/modules/companies/src/api/domain/process.aggregate.ts new file mode 100644 index 0000000..0a55707 --- /dev/null +++ b/modules/companies/src/api/domain/process.aggregate.ts @@ -0,0 +1,146 @@ +import { AggregateRoot, type UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; +import { z } from "zod/v4"; + +import { PersonProcessRevision } from "./process-revision.entity"; +import { ProcessTask } from "./process-task.entity"; +import { ProcessError, type ProcessReferenceInput } from "./process-values"; +import { ProcessValues } from "./value-objects/process-values"; + +type Props = Readonly<{ + companyId: UniqueID; + values: ProcessValues; + history: readonly PersonProcessRevision[]; +}>; +export class PersonProcess extends AggregateRoot { + private constructor(props: Props, id?: UniqueID) { + super(Object.freeze({ ...props, history: Object.freeze([...props.history]) }), id); + } + static create( + companyId: UniqueID, + values: ProcessValues, + actor: Maybe, + recordedAt: string + ): Result { + if ( + !(z.iso.datetime().safeParse(recordedAt).success && actor.getOrUndefined()) || + values.state !== "open" || + values.tasks.some((t) => t.toPrimitive().status !== "pending") + ) { + return Result.fail(new ProcessError("INVALID_REQUEST")); + } + return Result.ok( + new PersonProcess({ + companyId, + values, + history: [PersonProcessRevision.create({ values, actorId: actor, recordedAt })], + }) + ); + } + static rehydrate(props: Props, id: UniqueID) { + return new PersonProcess(props, id); + } + get companyId() { + return this.props.companyId; + } + get values() { + return this.props.values; + } + get history() { + return this.props.history; + } + update( + values: ProcessValues, + actor: 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 ProcessError("INVALID_REQUEST")); + } + if (this.values.closed) { + return Result.fail(new ProcessError("PROCESS_CLOSED")); + } + if (!this.values.sameSubject(values)) { + return Result.fail(new ProcessError("PROCESS_SUBJECT_IMMUTABLE")); + } + if (!this.values.sameTaskDefinitions(values)) { + return Result.fail(new ProcessError("PROCESS_TASKS_IMMUTABLE")); + } + if (values.state === "completed" && !values.complete) { + return Result.fail(new ProcessError("PROCESS_PENDING_TASKS")); + } + return Result.ok( + new PersonProcess( + { + companyId: this.companyId, + values, + history: [ + ...this.history, + PersonProcessRevision.create({ values, actorId: Maybe.some(actor), recordedAt }), + ], + }, + this.id + ) + ); + } + appendReviews( + references: readonly ProcessReferenceInput[], + actor: UniqueID, + recordedAt: string + ): Result { + if (this.values.closed) { + return Result.fail(new ProcessError("PROCESS_CLOSED")); + } + if (this.values.kind === "onboarding") { + return Result.ok(this); + } + const known = new Set( + this.values.tasks.flatMap((t) => { + const ref = t.toPrimitive().reference; + return ref ? [`${ref.kind}:${ref.id}`] : []; + }) + ); + const additions = references + .filter((ref) => !known.has(`${ref.kind}:${ref.id}`)) + .map((ref) => ProcessTask.create(ref.label.slice(0, 160), ref)); + if (!additions.length) { + return Result.ok(this); + } + if ( + !z.iso.datetime().safeParse(recordedAt).success || + recordedAt < (this.history.at(-1)?.recordedAt ?? "") + ) { + return Result.fail(new ProcessError("INVALID_REQUEST")); + } + const values = ProcessValues.create({ + ...this.values.toPrimitive(), + tasks: [...this.values.tasks, ...additions].map((t) => t.toPrimitive()), + }); + if (!values.isSuccess) { + return values; + } + return Result.ok( + new PersonProcess( + { + companyId: this.companyId, + values: values.data, + history: [ + ...this.history, + PersonProcessRevision.create({ + values: values.data, + actorId: Maybe.some(actor), + recordedAt, + }), + ], + }, + this.id + ) + ); + } +} diff --git a/modules/companies/src/api/domain/process.test.ts b/modules/companies/src/api/domain/process.test.ts new file mode 100644 index 0000000..deee67d --- /dev/null +++ b/modules/companies/src/api/domain/process.test.ts @@ -0,0 +1,285 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { UniqueID } from "@repo/rdx-ddd"; +import { Maybe } from "@repo/rdx-utils"; + +import { + type ProcessReviewSources, + buildProcessReview, +} from "../application/process-review.builder"; +import { PersonProcessPersistenceMapper } from "../infrastructure/persistence/process-persistence.mapper"; + +import { PersonProcess } from "./process.aggregate"; +import { ProcessTask } from "./process-task.entity"; +import { ProcessTemplate } from "./process-template.aggregate"; +import { + ProcessTemplateInputSchema, + type ProcessValuesInput, + ProcessValuesSchema, + StartProcessInputSchema, +} from "./process-values"; +import { ProcessTemplateValues } from "./value-objects/process-template-values"; +import { ProcessValues } from "./value-objects/process-values"; + +const company = UniqueID.generateNewID(); +const actor = UniqueID.generateNewID(); +const person = UniqueID.generateNewID(); +const templateId = UniqueID.generateNewID(); +const now = "2026-10-06T12:00:00.000Z"; +function values(input?: ProcessValuesInput) { + const result = ProcessValues.create( + input ?? { + personId: person.toPrimitive(), + templateId: templateId.toPrimitive(), + templateName: "Salida", + kind: "offboarding", + name: "Salida Ana", + state: "open", + notes: null, + tasks: [ProcessTask.create("Revisar correo").toPrimitive()], + } + ); + assert.ok(result.isSuccess); + return result.data; +} +function process() { + const result = PersonProcess.create(company, values(), Maybe.some(actor), now); + assert.ok(result.isSuccess); + return result.data; +} +test("procesos: contratos estrictos, normalización y límites", () => { + const input = values().toPrimitive(); + assert.ok(ProcessValuesSchema.safeParse(input).success); + assert.equal(ProcessValuesSchema.safeParse({ ...input, extra: true }).success, false); + assert.equal( + ProcessValuesSchema.safeParse({ ...input, tasks: [input.tasks[0], input.tasks[0]] }).success, + false + ); + assert.equal( + ProcessTemplateInputSchema.safeParse({ + name: "Plantilla", + kind: "change", + active: true, + notes: null, + instructions: ["Duplicada", "duplicada"], + }).success, + false + ); + assert.equal( + StartProcessInputSchema.safeParse({ + personId: person.toPrimitive(), + templateId: templateId.toPrimitive(), + name: "A", + notes: null, + actorId: actor.toPrimitive(), + }).success, + false + ); +}); +test("procesos: UUID v7, tareas con identidad, actualización idempotente e historial", () => { + const initial = process(); + assert.equal(initial.id.toPrimitive()[14], "7"); + assert.equal(initial.values.tasks[0]?.id.toPrimitive()[14], "7"); + assert.equal(initial.history[0]?.id.toPrimitive()[14], "7"); + const identical = initial.update(values(initial.values.toPrimitive()), actor, now); + assert.ok(identical.isSuccess); + assert.equal(identical.data, initial); + const changed = initial.update( + values({ ...initial.values.toPrimitive(), notes: "Revisión" }), + actor, + now + ); + assert.ok(changed.isSuccess); + assert.ok(changed.data.id.equals(initial.id)); + assert.equal(changed.data.history.length, 2); + assert.equal(initial.history.length, 1); + assert.equal( + changed.data.history[1]?.actorId.getOrUndefined()?.toPrimitive(), + actor.toPrimitive() + ); +}); +test("procesos: completar exige todas resueltas; cierre terminal y sujeto/tareas fijos", () => { + const initial = process(); + const primitive = initial.values.toPrimitive(); + assert.equal( + initial.update(values({ ...primitive, state: "completed" }), actor, now).isSuccess, + false + ); + assert.equal( + initial.update( + values({ ...primitive, personId: UniqueID.generateNewID().toPrimitive() }), + actor, + now + ).isSuccess, + false + ); + assert.equal( + initial.update( + values({ ...primitive, tasks: primitive.tasks.map((t) => ({ ...t, title: "Otra tarea" })) }), + actor, + now + ).isSuccess, + false + ); + const finished = initial.update( + values({ + ...primitive, + state: "completed", + tasks: primitive.tasks.map((t) => ({ ...t, status: "not_applicable" })), + }), + actor, + now + ); + assert.ok(finished.isSuccess); + assert.equal( + finished.data.update( + values({ ...finished.data.values.toPrimitive(), notes: "Alterar cerrado" }), + actor, + now + ).isSuccess, + false + ); + const same = finished.data.update(finished.data.values, actor, now); + assert.ok(same.isSuccess); + assert.equal(same.data, finished.data); +}); +test("procesos: añadir revisiones conserva las resueltas e identidades y no duplica", () => { + const initial = process(); + const reference = { + kind: "remote" as const, + id: UniqueID.generateNewID().toPrimitive(), + label: "VPN", + sourceKind: "personal_computer" as const, + }; + const appended = initial.appendReviews([reference], actor, now); + assert.ok(appended.isSuccess); + assert.equal(appended.data.values.tasks.length, 2); + assert.ok(appended.data.values.tasks[0]?.id.equals(initial.values.tasks[0]?.id)); + const primitive = appended.data.values.toPrimitive(); + const resolved = appended.data.update( + values({ ...primitive, tasks: primitive.tasks.map((t) => ({ ...t, status: "done" })) }), + actor, + now + ); + assert.ok(resolved.isSuccess); + const repeat = resolved.data.appendReviews([reference], actor, now); + assert.ok(repeat.isSuccess); + assert.equal(repeat.data, resolved.data); + assert.equal(repeat.data.history.length, 3); +}); +test("plantillas: copia de instrucciones, igualdad semántica y cambios independientes", () => { + const result = ProcessTemplateValues.create({ + name: "Salida", + kind: "offboarding", + active: true, + notes: null, + instructions: ["Comprobar"], + }); + assert.ok(result.isSuccess); + const template = ProcessTemplate.create(result.data, Maybe.some(actor), now); + assert.ok(template.isSuccess); + const initial = process(); + const input = { ...result.data.toPrimitive(), instructions: ["Nueva lista"], active: false }; + const changed = ProcessTemplateValues.create(input); + assert.ok(changed.isSuccess); + const updated = template.data.update(changed.data, actor, now); + assert.ok(updated.isSuccess); + assert.equal(updated.data.history.length, 2); + assert.equal(initial.values.tasks[0]?.toPrimitive().title, "Revisar correo"); +}); +test("procesos: rehidratación bidireccional y rechazo de raíces/historial alterados", () => { + const mapper = new PersonProcessPersistenceMapper(); + const initial = process(); + const record = mapper.toPersistence(initial); + assert.deepEqual( + mapper.toDomain(record.root, record.revisions).values.toPrimitive(), + initial.values.toPrimitive() + ); + assert.throws(() => mapper.toDomain({ ...record.root, currentKey: "bad" }, record.revisions)); + assert.throws(() => mapper.toDomain(record.root, [])); + assert.throws(() => + mapper.toDomain( + record.root, + record.revisions.map((r) => ({ ...r, actorId: null })) + ) + ); + assert.throws(() => + mapper.toDomain( + record.root, + record.revisions.map((r) => ({ ...r, position: 3 })) + ) + ); +}); +const sources: ProcessReviewSources = { + assets: [ + { + id: UniqueID.generateNewID().toPrimitive(), + personId: person.toPrimitive(), + name: "Portátil", + available: true, + }, + ], + profiles: [], + authorizations: [], + configurations: [ + { + id: UniqueID.generateNewID().toPrimitive(), + personId: person.toPrimitive(), + kind: "remote", + label: "VPN móvil", + state: "configured", + sourceKind: "personal_mobile", + }, + { + id: UniqueID.generateNewID().toPrimitive(), + personId: person.toPrimitive(), + kind: "mail", + label: "Cerrado", + state: "removed", + }, + { + id: UniqueID.generateNewID().toPrimitive(), + personId: actor.toPrimitive(), + kind: "mail", + label: "Otra persona", + state: "configured", + }, + ], + relationships: [ + { + id: UniqueID.generateNewID().toPrimitive(), + personId: person.toPrimitive(), + kind: "account", + label: "Cuenta antigua aún configurada", + endDate: "2000-01-01", + configuration: "configured", + }, + ], +}; +test("revisión: personales, retirada pendiente y relaciones finalizadas aún configuradas", () => { + const review = buildProcessReview(person.toPrimitive(), sources, "2026-10-06"); + assert.equal(review.length, 3); + assert.ok(review.some((r) => r.kind === "remote" && r.sourceKind === "personal_mobile")); + assert.ok(review.some((r) => r.kind === "asset")); + assert.ok(review.some((r) => r.kind === "account")); +}); +test("revisión: incluye configuraciones de perfiles archivados, excluye cerradas y evita duplicar", () => { + const input = { + ...sources, + relationships: [...sources.relationships, ...sources.relationships], + configurations: [ + ...sources.configurations, + { + id: UniqueID.generateNewID().toPrimitive(), + personId: person.toPrimitive(), + kind: "printer" as const, + label: "Archivado pero por retirar", + state: "removal_pending", + }, + ], + }; + const review = buildProcessReview(person.toPrimitive(), input, "2026-10-06"); + assert.equal(review.length, 4); + assert.ok(review.some((r) => r.label === "Archivado pero por retirar")); +}); diff --git a/modules/companies/src/api/domain/value-objects/process-template-values.ts b/modules/companies/src/api/domain/value-objects/process-template-values.ts new file mode 100644 index 0000000..c64184a --- /dev/null +++ b/modules/companies/src/api/domain/value-objects/process-template-values.ts @@ -0,0 +1,65 @@ +import { Name } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { + ProcessError, + type ProcessTemplateInput, + ProcessTemplateInputSchema, +} from "../process-values"; +export class ProcessTemplateValues { + private constructor( + public readonly name: Name, + public readonly kind: ProcessTemplateInput["kind"], + public readonly active: boolean, + public readonly notes: Maybe, + public readonly instructions: readonly Name[] + ) { + Object.freeze(this); + } + static create(input: ProcessTemplateInput): Result { + const parsed = ProcessTemplateInputSchema.safeParse(input); + if (!parsed.success) { + return Result.fail(new ProcessError("INVALID_REQUEST")); + } + const name = Name.create(parsed.data.name, 160); + if (!name.isSuccess) { + return Result.fail(new ProcessError("INVALID_REQUEST")); + } + const instructions: Name[] = []; + for (const value of parsed.data.instructions) { + const item = Name.create(value, 160); + if (!item.isSuccess) { + return Result.fail(new ProcessError("INVALID_REQUEST")); + } + instructions.push(item.data); + } + return Result.ok( + new ProcessTemplateValues( + name.data, + parsed.data.kind, + parsed.data.active, + parsed.data.notes ? Maybe.some(parsed.data.notes) : Maybe.none(), + Object.freeze(instructions) + ) + ); + } + equals(other: ProcessTemplateValues) { + return ( + this.name.equals(other.name) && + this.kind === other.kind && + this.active === other.active && + this.notes.getOrUndefined() === other.notes.getOrUndefined() && + this.instructions.length === other.instructions.length && + this.instructions.every((item, index) => item.equals(other.instructions[index])) + ); + } + toPrimitive(): ProcessTemplateInput { + return { + name: this.name.toPrimitive(), + kind: this.kind, + active: this.active, + notes: this.notes.getOrUndefined() ?? null, + instructions: this.instructions.map((n) => n.toPrimitive()), + }; + } +} diff --git a/modules/companies/src/api/domain/value-objects/process-values.ts b/modules/companies/src/api/domain/value-objects/process-values.ts new file mode 100644 index 0000000..477f9bf --- /dev/null +++ b/modules/companies/src/api/domain/value-objects/process-values.ts @@ -0,0 +1,101 @@ +import { Name, UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { ProcessTask } from "../process-task.entity"; +import { ProcessError, type ProcessValuesInput, ProcessValuesSchema } from "../process-values"; +export class ProcessValues { + private constructor( + public readonly personId: UniqueID, + public readonly templateId: UniqueID, + public readonly name: Name, + public readonly notes: Maybe, + private readonly data: Readonly>, + public readonly tasks: readonly ProcessTask[] + ) { + Object.freeze(this); + } + static create(input: ProcessValuesInput): Result { + const parsed = ProcessValuesSchema.safeParse(input); + if (!parsed.success) { + return Result.fail(new ProcessError("INVALID_REQUEST")); + } + const v = parsed.data; + const person = UniqueID.create(v.personId); + const template = UniqueID.create(v.templateId); + const name = Name.create(v.name, 160); + if (!(person.isSuccess && template.isSuccess && name.isSuccess)) { + return Result.fail(new ProcessError("INVALID_REQUEST")); + } + const tasks = Object.freeze(v.tasks.map((task) => ProcessTask.rehydrate(task))); + return Result.ok( + new ProcessValues( + person.data, + template.data, + name.data, + v.notes ? Maybe.some(v.notes) : Maybe.none(), + Object.freeze({ ...v }), + tasks + ) + ); + } + get state() { + return this.data.state; + } + get kind() { + return this.data.kind; + } + get closed() { + return this.state !== "open"; + } + get complete() { + return this.tasks.every((t) => t.toPrimitive().status !== "pending"); + } + sameSubject(other: ProcessValues) { + return ( + this.personId.equals(other.personId) && + this.templateId.equals(other.templateId) && + this.kind === other.kind && + this.data.templateName === other.data.templateName + ); + } + sameTaskDefinitions(other: ProcessValues) { + return ( + this.tasks.length === other.tasks.length && + this.tasks.every((t, index) => { + const a = t.toPrimitive(); + const b = other.tasks[index]?.toPrimitive(); + return ( + a.id === b?.id && + a.title === b.title && + a.reference?.kind === b.reference?.kind && + a.reference?.id === b.reference?.id && + a.reference?.label === b.reference?.label && + a.reference?.sourceKind === b.reference?.sourceKind + ); + }) + ); + } + equals(other: ProcessValues) { + return ( + this.sameSubject(other) && + this.name.equals(other.name) && + this.state === other.state && + this.notes.getOrUndefined() === other.notes.getOrUndefined() && + this.tasks.length === other.tasks.length && + this.tasks.every((t, index) => { + const otherTask = other.tasks[index]; + return Boolean(otherTask && t.sameValues(otherTask)); + }) + ); + } + toPrimitive(): ProcessValuesInput { + return { + ...this.data, + personId: this.personId.toPrimitive(), + templateId: this.templateId.toPrimitive(), + name: this.name.toPrimitive(), + notes: this.notes.getOrUndefined() ?? null, + tasks: this.tasks.map((t) => t.toPrimitive()), + }; + } +} diff --git a/modules/companies/src/api/infrastructure/http/process.routes.ts b/modules/companies/src/api/infrastructure/http/process.routes.ts new file mode 100644 index 0000000..dcdfdb4 --- /dev/null +++ b/modules/companies/src/api/infrastructure/http/process.routes.ts @@ -0,0 +1,183 @@ +import { type Application, type RequestHandler, type Response, Router } from "express"; +import { z } from "zod/v4"; + +import { + ProcessTemplateInputSchema, + StartProcessInputSchema, + UpdateProcessInputSchema, +} from "../../../common/processes"; +import type { ProcessUseCases } from "../../application/process.use-cases"; +import { buildProcess, buildProcessTemplate } from "../../application/process-response.builder"; + +const companyParams = z.strictObject({ companyId: z.uuid().transform((v) => v.toLowerCase()) }); +const itemParams = companyParams.extend({ id: z.uuid().transform((v) => v.toLowerCase()) }); +const templateParams = z.strictObject({ id: z.uuid().transform((v) => v.toLowerCase()) }); +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") }); +} +function actorOf(request: unknown) { + return z.object({ session: z.object({ userId: z.uuid() }) }).safeParse(request); +} +export function registerProcessRoutes( + app: Application, + useCases: ProcessUseCases, + authenticate: RequestHandler +) { + const router = Router(); + router.use((request, response, next) => { + if (!z.strictObject({}).safeParse(request.query).success) { + fail(response, "INVALID_REQUEST"); + return; + } + next(); + }); + router.get("/templates", (_request, response, next) => { + useCases + .catalog() + .then((result) => { + if (!result.isSuccess) { + fail(response, result.error.code); + return; + } + response.json({ + templates: result.data.templates.map((t) => buildProcessTemplate(t, result.data)), + }); + }) + .catch(next); + }); + for (const path of ["/templates", "/templates/:id/update"]) { + router.post(path, (request, response, next) => { + const body = z.strictObject({ values: ProcessTemplateInputSchema }).safeParse(request.body); + const params = path.endsWith("update") + ? templateParams.safeParse(request.params) + : z.strictObject({}).safeParse(request.params); + const actor = actorOf(request); + if (!(body.success && params.success && actor.success)) { + fail(response, "INVALID_REQUEST"); + return; + } + const id = + "id" in params.data && typeof params.data.id === "string" ? params.data.id : undefined; + useCases + .saveTemplate(body.data.values, actor.data.session.userId, id) + .then((result) => { + if (!result.isSuccess) { + fail(response, result.error.code); + return; + } + response + .status(id ? 200 : 201) + .json({ template: buildProcessTemplate(result.data.template, result.data.context) }); + }) + .catch(next); + }); + } + router.get("/:companyId", (request, response, next) => { + const params = companyParams.safeParse(request.params); + if (!params.success) { + fail(response, "INVALID_REQUEST"); + return; + } + useCases + .get(params.data.companyId) + .then((result) => { + if (!result.isSuccess) { + fail(response, result.error.code); + return; + } + response.json({ + processes: result.data.processes.map((p) => buildProcess(p, result.data)), + }); + }) + .catch(next); + }); + router.get("/:companyId/people/:id/review", (request, response, next) => { + const params = itemParams.safeParse(request.params); + if (!params.success) { + fail(response, "INVALID_REQUEST"); + return; + } + useCases + .get(params.data.companyId) + .then((result) => { + if (!result.isSuccess) { + fail(response, result.error.code); + return; + } + const review = result.data.reviews.get(params.data.id); + if (!review) { + fail(response, "PROCESS_PERSON_NOT_FOUND"); + return; + } + response.json({ review }); + }) + .catch(next); + }); + router.post("/:companyId", (request, response, next) => { + const params = companyParams.safeParse(request.params); + const body = z.strictObject({ values: StartProcessInputSchema }).safeParse(request.body); + const actor = actorOf(request); + if (!(params.success && body.success && actor.success)) { + fail(response, "INVALID_REQUEST"); + return; + } + useCases + .start(params.data.companyId, body.data.values, actor.data.session.userId) + .then((result) => { + if (!result.isSuccess) { + fail(response, result.error.code); + return; + } + response + .status(201) + .json({ process: buildProcess(result.data.process, result.data.context) }); + }) + .catch(next); + }); + router.post("/:companyId/:id/update", (request, response, next) => { + const params = itemParams.safeParse(request.params); + const body = z.strictObject({ values: UpdateProcessInputSchema }).safeParse(request.body); + const actor = actorOf(request); + if (!(params.success && body.success && actor.success)) { + fail(response, "INVALID_REQUEST"); + return; + } + useCases + .update(params.data.companyId, params.data.id, body.data.values, actor.data.session.userId) + .then((result) => { + if (!result.isSuccess) { + fail(response, result.error.code); + return; + } + response.json({ process: buildProcess(result.data.process, result.data.context) }); + }) + .catch(next); + }); + router.post("/:companyId/:id/refresh", (request, response, next) => { + const params = itemParams.safeParse(request.params); + const body = z.strictObject({ revisionId: z.uuid() }).safeParse(request.body); + const actor = actorOf(request); + if (!(params.success && body.success && actor.success)) { + fail(response, "INVALID_REQUEST"); + return; + } + useCases + .refresh( + params.data.companyId, + params.data.id, + body.data.revisionId, + actor.data.session.userId + ) + .then((result) => { + if (!result.isSuccess) { + fail(response, result.error.code); + return; + } + response.json({ process: buildProcess(result.data.process, result.data.context) }); + }) + .catch(next); + }); + app.use("/api/processes", authenticate, router); +} diff --git a/modules/companies/src/api/infrastructure/persistence/process-migration.ts b/modules/companies/src/api/infrastructure/persistence/process-migration.ts new file mode 100644 index 0000000..c6ccdbb --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/process-migration.ts @@ -0,0 +1,173 @@ +import { Maybe } from "@repo/rdx-utils"; +import { DataTypes, QueryTypes, type Sequelize, type Transaction } from "sequelize"; + +import { ProcessTemplate } from "../../domain/process-template.aggregate"; +import { ProcessTemplateValues } from "../../domain/value-objects/process-template-values"; + +import { + ProcessTemplatePersistenceMapper, + processTemplateNameKey, +} from "./process-template-persistence.mapper"; +export async function migrateProcesses(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 = (table: string, nullable = false) => ({ + type: DataTypes.UUID, + allowNull: nullable, + references: { model: table, key: "id" }, + onDelete: "RESTRICT", + }); + const root = { + id: { type: DataTypes.UUID, primaryKey: true }, + values: { type: DataTypes.JSON, allowNull: false }, + }; + const revision = { + id: root.id, + position: { type: DataTypes.INTEGER, allowNull: false }, + values: root.values, + actorId: fk("users", true), + recordedAt: { type: DataTypes.STRING(24), allowNull: false }, + }; + for (const [table, columns] of [ + ["process_catalog_lock", { id: { type: DataTypes.INTEGER, primaryKey: true } }], + [ + "process_templates", + { + ...root, + nameKey: { type: DataTypes.STRING(160), allowNull: false, unique: true }, + builtinKey: { type: DataTypes.STRING(30), allowNull: true, unique: true }, + }, + ], + ["process_template_revisions", { ...revision, itemId: fk("process_templates") }], + [ + "person_processes", + { + ...root, + companyId: fk("companies"), + personId: fk("company_contacts"), + templateId: fk("process_templates"), + currentKey: { type: DataTypes.STRING(100), allowNull: true, unique: true }, + }, + ], + ["person_process_revisions", { ...revision, itemId: fk("person_processes") }], + ] as const) { + if (!tables.some((t) => t.name === table)) { + await query.createTable(table, columns); + } + } + for (const table of ["process_template_revisions", "person_process_revisions"]) { + const indexes = await database.query<{ name: string }>( + "SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=:table", + { replacements: { table }, type: QueryTypes.SELECT } + ); + if (!indexes.some((i) => i.name === `${table}_position`)) { + await query.addIndex(table, ["itemId", "position"], { + unique: true, + name: `${table}_position`, + }); + } + } + await database.query("INSERT IGNORE INTO process_catalog_lock (id) VALUES (1)"); + const defaults = [ + { + key: "onboarding", + name: "Alta de persona", + kind: "onboarding", + instructions: [ + "Revisar persona, departamentos y sede", + "Asignar activos y puesto de trabajo", + "Preparar cuentas, permisos y correo", + "Configurar perfil, programas, impresoras y certificados", + "Comprobar teletrabajo y accesos remotos si procede", + "Verificar con la persona que puede trabajar", + ], + }, + { + key: "change", + name: "Cambio de equipo o funciones", + kind: "change", + instructions: [ + "Confirmar qué necesita la persona en su nuevo entorno", + "Revisar los accesos que conserva y los que cambian", + "Preparar el nuevo equipo y perfil", + "Trasladar configuraciones y comprobar el resultado", + "Retirar o reasignar equipos y configuraciones anteriores", + ], + }, + { + key: "offboarding", + name: "Salida de persona", + kind: "offboarding", + instructions: [ + "Confirmar la salida y conservar los datos necesarios", + "Revisar cuentas personales y compartidas sin afectar a otros usuarios", + "Cerrar usos de correo, grupos y permisos que correspondan", + "Revisar móviles y ordenadores personales con acceso corporativo", + "Revocar teletrabajo y retirar accesos remotos", + "Recuperar o reasignar los activos de la compañía", + "Retirar configuraciones y archivar perfiles cuando proceda", + "Desactivar a la persona y documentar las comprobaciones", + ], + }, + ] as const; + await database.transaction(async (transaction) => { + await database.query("SELECT id FROM process_catalog_lock WHERE id=1 FOR UPDATE", { + transaction, + type: QueryTypes.SELECT, + }); + await normalizeTemplateNameKeys(database, transaction); + for (const item of defaults) { + const existing = await database.query<{ id: string }>( + "SELECT id FROM process_templates WHERE builtinKey=:key", + { replacements: { key: item.key }, transaction, type: QueryTypes.SELECT } + ); + if (existing.length) { + continue; + } + const values = ProcessTemplateValues.create({ + name: item.name, + kind: item.kind, + active: true, + notes: null, + instructions: [...item.instructions], + }); + if (!values.isSuccess) { + throw new Error("Plantilla inicial inválida."); + } + const template = ProcessTemplate.create(values.data, Maybe.none(), new Date().toISOString()); + if (!template.isSuccess) { + throw new Error("Plantilla inicial inconsistente."); + } + const record = new ProcessTemplatePersistenceMapper().toPersistence(template.data); + await database.query( + "INSERT INTO process_templates (id,nameKey,builtinKey,`values`) VALUES (:id,:nameKey,:builtinKey,:values)", + { replacements: { ...record.root, builtinKey: item.key }, transaction } + ); + for (const revision of record.revisions) { + await database.query( + "INSERT INTO process_template_revisions (id,itemId,position,actorId,recordedAt,`values`) VALUES (:id,:itemId,:position,:actorId,:recordedAt,:values)", + { replacements: revision, transaction } + ); + } + } + }); +} + +async function normalizeTemplateNameKeys(database: Sequelize, transaction: Transaction) { + const existingNames = await database.query<{ id: string; nameKey: string; name: string }>( + "SELECT id,nameKey,JSON_UNQUOTE(JSON_EXTRACT(`values`, '$.name')) AS name FROM process_templates", + { transaction, type: QueryTypes.SELECT } + ); + for (const row of existingNames) { + const nameKey = processTemplateNameKey(row.name); + if (row.nameKey !== nameKey) { + await database.query("UPDATE process_templates SET nameKey=:nameKey WHERE id=:id", { + replacements: { nameKey, id: row.id }, + transaction, + }); + } + } +} diff --git a/modules/companies/src/api/infrastructure/persistence/process-persistence.mapper.ts b/modules/companies/src/api/infrastructure/persistence/process-persistence.mapper.ts new file mode 100644 index 0000000..a6820e9 --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/process-persistence.mapper.ts @@ -0,0 +1,135 @@ +import { UniqueID } from "@repo/rdx-ddd"; +import { Maybe } from "@repo/rdx-utils"; +import { z } from "zod/v4"; + +import { PersonProcess } from "../../domain/process.aggregate"; +import { PersonProcessRevision } from "../../domain/process-revision.entity"; +import { ProcessValuesSchema } from "../../domain/process-values"; +import { ProcessValues } from "../../domain/value-objects/process-values"; + +import { persistedJson } from "./work-profile-persistence.mapper"; +export type PersonProcessRow = { + id: string; + companyId: string; + personId: string; + templateId: string; + currentKey: string | null; + values: unknown; +}; +export type PersonProcessRevisionRow = { + id: string; + itemId: string; + position: number; + actorId: string | null; + recordedAt: string; + values: unknown; +}; +function identity(value: string) { + const parsed = UniqueID.create(value); + if (!parsed.isSuccess) { + throw new Error("Identidad de proceso inconsistente."); + } + return parsed.data; +} +function mapped(raw: unknown) { + const values = ProcessValues.create(ProcessValuesSchema.parse(persistedJson(raw))); + if (!values.isSuccess) { + throw new Error("Valores de proceso inconsistentes."); + } + return values.data; +} +function validateTransitions(history: readonly PersonProcessRevision[]) { + if ( + history[0]?.values.state !== "open" || + history[0].values.tasks.some((t) => t.toPrimitive().status !== "pending") + ) { + throw new Error("Inicio de proceso inconsistente."); + } + for (const [index, r] of history.entries()) { + const previous = history[index - 1]?.values; + if (r.values.state === "completed" && !r.values.complete) { + throw new Error("Proceso completado con tareas pendientes."); + } + if (previous) { + const old = previous.toPrimitive().tasks; + const current = r.values.toPrimitive().tasks; + const prefix = ProcessValues.create({ + ...r.values.toPrimitive(), + tasks: current.slice(0, old.length), + }); + if ( + !r.values.sameSubject(previous) || + (previous.closed && !r.values.equals(previous)) || + current.length < old.length || + !prefix.isSuccess || + !previous.sameTaskDefinitions(prefix.data) || + current.slice(old.length).some((t) => !t.reference || t.status !== "pending") || + (current.length > old.length && + (r.values.state !== "open" || r.values.kind === "onboarding")) + ) { + throw new Error("Transición de proceso inconsistente."); + } + } + } +} +export class PersonProcessPersistenceMapper { + toDomain(row: PersonProcessRow, revisions: readonly PersonProcessRevisionRow[]) { + const values = mapped(row.values); + const ordered = [...revisions].sort((a, b) => a.position - b.position); + const history = ordered.map((r, index) => { + if ( + r.itemId !== row.id || + r.position !== index || + !z.iso.datetime().safeParse(r.recordedAt).success || + r.recordedAt < (ordered[index - 1]?.recordedAt ?? "") || + !r.actorId + ) { + throw new Error("Historial de proceso inconsistente."); + } + return PersonProcessRevision.rehydrate( + { + values: mapped(r.values), + actorId: r.actorId ? Maybe.some(identity(r.actorId)) : Maybe.none(), + recordedAt: r.recordedAt, + }, + identity(r.id) + ); + }); + if ( + !(history.length && history.at(-1)?.values.equals(values)) || + row.personId !== values.personId.toPrimitive() || + row.templateId !== values.templateId.toPrimitive() || + row.currentKey !== (values.closed ? null : `${row.companyId}:${row.personId}:${values.kind}`) + ) { + throw new Error("Raíz de proceso inconsistente."); + } + validateTransitions(history); + return PersonProcess.rehydrate( + { companyId: identity(row.companyId), values, history }, + identity(row.id) + ); + } + toPersistence(item: PersonProcess) { + const values = item.values.toPrimitive(); + return { + root: { + id: item.id.toPrimitive(), + companyId: item.companyId.toPrimitive(), + personId: values.personId, + templateId: values.templateId, + currentKey: item.values.closed + ? null + : `${item.companyId.toPrimitive()}:${values.personId}:${values.kind}`, + values: JSON.stringify(values), + }, + revisions: item.history.map((r, position) => ({ + id: r.id.toPrimitive(), + itemId: item.id.toPrimitive(), + position, + actorId: r.actorId.getOrUndefined()?.toPrimitive() ?? null, + recordedAt: r.recordedAt, + values: JSON.stringify(r.values.toPrimitive()), + })), + }; + } +} diff --git a/modules/companies/src/api/infrastructure/persistence/process-review-reader.ts b/modules/companies/src/api/infrastructure/persistence/process-review-reader.ts new file mode 100644 index 0000000..fbcf793 --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/process-review-reader.ts @@ -0,0 +1,235 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import { QueryTypes, type Sequelize, type Transaction } from "sequelize"; + +import type { IProcessAssetInventoryService } from "../../application/process.repository"; +import { + type ProcessReviewSources, + buildProcessReview, +} from "../../application/process-review.builder"; +import { accessReviewRelationships } from "../../application/process-review-access"; +import { AccessDefinitionSchema } from "../../domain/access/access-definition"; +import { ApplicationConfigurationInputSchema } from "../../domain/application-configuration-values"; +import { CertificateConfigurationInputSchema } from "../../domain/certificate-configuration-values"; +import { FolderMappingInputSchema } from "../../domain/folder-mapping-values"; +import { MailSnapshotSchema } from "../../domain/mail-configuration-values"; +import { MailboxUseInputSchema } from "../../domain/mailbox-values"; +import { PrinterConfigurationInputSchema } from "../../domain/printer-configuration-values"; +import { RemoteConnectionInputSchema } from "../../domain/remote-connection-values"; +import { TeleworkAuthorizationInputSchema } from "../../domain/telework-authorization-values"; +import type { WorkProfileInput } from "../../domain/work-profile-values"; +import { WorkProfileInputSchema } from "../../domain/work-profile-values"; + +import { persistedJson, profileIdentity } from "./work-profile-persistence.mapper"; + +function configurationDetail( + value: { + profileId: string; + path?: string; + location?: string | null; + version?: string | null; + resourceId?: string; + printerId?: string; + }, + assetNames: ReadonlyMap, + resourceNames: ReadonlyMap +) { + const target = value.resourceId + ? resourceNames.get(value.resourceId) + : value.printerId + ? assetNames.get(value.printerId) + : undefined; + return [target, value.path ?? value.location ?? value.version].filter(Boolean).join(" · "); +} +type Profile = { id: string; values: WorkProfileInput }; +type Row = { id: string; values: unknown }; +type Table = + | "work_profiles" + | "mail_configurations" + | "folder_mappings" + | "printer_configurations" + | "application_configurations" + | "certificate_configurations" + | "remote_connections" + | "mailbox_uses" + | "telework_authorizations" + | "mailboxes"; +function profileOf(profiles: readonly Profile[], id: string) { + const p = profiles.find((p) => p.id === id); + if (!p) { + throw new Error("Perfil de revisión inconsistente."); + } + return p; +} +export class ProcessReviewReader { + constructor(private readonly database: Sequelize) {} + + private rows(table: Table, companyId: string, transaction: Transaction) { + return this.database.query( + `SELECT id,\`values\` FROM ${table} WHERE companyId=:companyId`, + { replacements: { companyId }, transaction, type: QueryTypes.SELECT } + ); + } + private mail(data: readonly Row[], profiles: readonly Profile[]) { + const configurations: ProcessReviewSources["configurations"][number][] = []; + for (const r of data) { + const v = MailSnapshotSchema.parse(persistedJson(r.values)); + const personId = + "profileId" in v ? profileOf(profiles, v.profileId).values.personId : v.personId; + configurations.push({ + id: profileIdentity(r.id).toPrimitive(), + personId, + kind: "mail", + label: v.address, + state: v.state, + }); + } + return configurations; + } + private remote( + data: readonly Row[], + assetNames: ReadonlyMap, + resourceNames: ReadonlyMap + ) { + const configurations: ProcessReviewSources["configurations"][number][] = []; + for (const r of data) { + const v = RemoteConnectionInputSchema.parse(persistedJson(r.values)); + configurations.push({ + id: profileIdentity(r.id).toPrimitive(), + personId: v.personId, + kind: "remote", + label: [ + v.method, + v.sourceAssetId ? assetNames.get(v.sourceAssetId) : null, + v.targetKind === "asset" + ? assetNames.get(v.targetId ?? "") + : v.targetKind === "resource" + ? resourceNames.get(v.targetId ?? "") + : null, + ] + .filter(Boolean) + .join(" · "), + sourceKind: v.sourceKind, + state: v.state, + }); + } + return configurations; + } + private async profileConfigurations( + companyId: string, + transaction: Transaction, + profiles: readonly Profile[], + assetNames: ReadonlyMap, + resourceNames: ReadonlyMap + ) { + const configurations: ProcessReviewSources["configurations"][number][] = []; + const tables = [ + ["folder_mappings", "folder", FolderMappingInputSchema], + ["printer_configurations", "printer", PrinterConfigurationInputSchema], + ["application_configurations", "application", ApplicationConfigurationInputSchema], + ["certificate_configurations", "certificate", CertificateConfigurationInputSchema], + ] as const; + for (const [table, kind, schema] of tables) { + for (const r of await this.rows(table, companyId, transaction)) { + const v = schema.parse(persistedJson(r.values)); + const p = profileOf(profiles, v.profileId); + const detail = configurationDetail(v, assetNames, resourceNames); + configurations.push({ + id: profileIdentity(r.id).toPrimitive(), + personId: p.values.personId, + kind, + label: detail ? `${p.values.name} · ${detail}` : p.values.name, + state: v.state, + }); + } + } + return configurations; + } + + async load( + companyId: string, + people: readonly { id: string; name: string }[], + transaction: Transaction, + assets: Awaited>, + resources: readonly { id: UniqueID; name: string }[] + ) { + const assetNames = new Map(assets.map((a) => [a.id.toPrimitive(), a.name])); + const resourceNames = new Map(resources.map((r) => [r.id.toPrimitive(), r.name])); + const rows = (table: Table) => this.rows(table, companyId, transaction); + const profiles = (await rows("work_profiles")).map((r) => ({ + id: profileIdentity(r.id).toPrimitive(), + values: WorkProfileInputSchema.parse(persistedJson(r.values)), + })); + const configurations = [ + ...this.mail(await rows("mail_configurations"), profiles), + ...(await this.profileConfigurations( + companyId, + transaction, + profiles, + assetNames, + resourceNames + )), + ...this.remote(await rows("remote_connections"), assetNames, resourceNames), + ]; + const relationships: ProcessReviewSources["relationships"][number][] = []; + const rawAccess = await this.database.query<{ id: string; values: unknown }>( + "SELECT i.id,i.`values` FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId WHERE w.companyId=:companyId", + { replacements: { companyId }, transaction, type: QueryTypes.SELECT } + ); + const access = rawAccess.map((r) => ({ + id: profileIdentity(r.id).toPrimitive(), + values: AccessDefinitionSchema.parse(persistedJson(r.values)), + })); + relationships.push(...accessReviewRelationships(access)); + const mailboxes = await this.database.query<{ id: string; address: string }>( + "SELECT id,JSON_UNQUOTE(JSON_EXTRACT(`values`, '$.address')) AS address FROM mailboxes WHERE companyId=:companyId", + { replacements: { companyId }, transaction, type: QueryTypes.SELECT } + ); + for (const r of await rows("mailbox_uses")) { + const v = MailboxUseInputSchema.parse(persistedJson(r.values)); + relationships.push({ + id: profileIdentity(r.id).toPrimitive(), + kind: "mailbox_use", + personId: v.personId, + label: mailboxes.find((m) => m.id === v.mailboxId)?.address ?? v.mailboxId, + endDate: v.endDate, + configuration: null, + }); + } + const authorizations = (await rows("telework_authorizations")).map((r) => ({ + id: profileIdentity(r.id).toPrimitive(), + ...TeleworkAuthorizationInputSchema.parse(persistedJson(r.values)), + })); + const sources = { + assets: assets.map((a) => ({ ...a, id: a.id.toPrimitive() })), + profiles: profiles.map((p) => ({ id: p.id, ...p.values })), + configurations, + relationships, + authorizations, + }; + const knownPeople = new Set(people.map((p) => p.id)); + const relatedPeople = [ + ...sources.profiles, + ...sources.configurations, + ...sources.relationships, + ...sources.authorizations, + ...sources.assets, + ]; + if (relatedPeople.some((item) => item.personId && !knownPeople.has(item.personId))) { + throw new Error("Ámbito de revisión inconsistente."); + } + const result = new Map( + people.map((person) => [ + person.id, + buildProcessReview(person.id, sources, new Date().toISOString().slice(0, 10)).map((ref) => + ref.kind === "telework" ? { ...ref, label: person.name } : ref + ), + ]) + ); + for (const refs of result.values()) { + if (new Set(refs.map((r) => `${r.kind}:${r.id}`)).size !== refs.length) { + throw new Error("Referencias de revisión duplicadas."); + } + } + return result; + } +} diff --git a/modules/companies/src/api/infrastructure/persistence/process-template-persistence.mapper.ts b/modules/companies/src/api/infrastructure/persistence/process-template-persistence.mapper.ts new file mode 100644 index 0000000..d968faa --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/process-template-persistence.mapper.ts @@ -0,0 +1,90 @@ +import { createHash } from "node:crypto"; + +import { UniqueID } from "@repo/rdx-ddd"; +import { Maybe } from "@repo/rdx-utils"; +import { z } from "zod/v4"; + +import { ProcessTemplate } from "../../domain/process-template.aggregate"; +import { ProcessTemplateRevision } from "../../domain/process-template-revision.entity"; +import { ProcessTemplateInputSchema } from "../../domain/process-values"; +import { ProcessTemplateValues } from "../../domain/value-objects/process-template-values"; + +import { persistedJson } from "./work-profile-persistence.mapper"; +export type ProcessTemplateRow = { id: string; nameKey: string; values: unknown }; +export type ProcessTemplateRevisionRow = { + id: string; + itemId: string; + position: number; + actorId: string | null; + recordedAt: string; + values: unknown; +}; +function identity(value: string) { + const parsed = UniqueID.create(value); + if (!parsed.isSuccess) { + throw new Error("Identidad de proceso inconsistente."); + } + return parsed.data; +} +function mapped(raw: unknown) { + const values = ProcessTemplateValues.create(ProcessTemplateInputSchema.parse(persistedJson(raw))); + if (!values.isSuccess) { + throw new Error("Valores de proceso inconsistentes."); + } + return values.data; +} +export class ProcessTemplatePersistenceMapper { + toDomain(row: ProcessTemplateRow, revisions: readonly ProcessTemplateRevisionRow[]) { + const values = mapped(row.values); + const ordered = [...revisions].sort((a, b) => a.position - b.position); + const history = ordered.map((r, index) => { + if ( + r.itemId !== row.id || + r.position !== index || + !z.iso.datetime().safeParse(r.recordedAt).success || + r.recordedAt < (ordered[index - 1]?.recordedAt ?? "") || + (!r.actorId && index !== 0) + ) { + throw new Error("Historial de proceso inconsistente."); + } + return ProcessTemplateRevision.rehydrate( + { + values: mapped(r.values), + actorId: r.actorId ? Maybe.some(identity(r.actorId)) : Maybe.none(), + recordedAt: r.recordedAt, + }, + identity(r.id) + ); + }); + if ( + !(history.length && history.at(-1)?.values.equals(values)) || + row.nameKey !== processTemplateNameKey(values.name.toPrimitive()) + ) { + throw new Error("Raíz de proceso inconsistente."); + } + + return ProcessTemplate.rehydrate({ values, history }, identity(row.id)); + } + toPersistence(item: ProcessTemplate) { + const values = item.values.toPrimitive(); + return { + root: { + id: item.id.toPrimitive(), + nameKey: processTemplateNameKey(values.name), + values: JSON.stringify(values), + }, + revisions: item.history.map((r, position) => ({ + id: r.id.toPrimitive(), + itemId: item.id.toPrimitive(), + position, + actorId: r.actorId.getOrUndefined()?.toPrimitive() ?? null, + recordedAt: r.recordedAt, + values: JSON.stringify(r.values.toPrimitive()), + })), + }; + } +} + +export function processTemplateNameKey(name: string) { + return createHash("sha256").update(name.toLowerCase()).digest("hex"); +} diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize-process.repository.ts b/modules/companies/src/api/infrastructure/persistence/sequelize-process.repository.ts new file mode 100644 index 0000000..2633d85 --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/sequelize-process.repository.ts @@ -0,0 +1,179 @@ +import type { IResourceInventoryService } from "@metis/resources/services"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import { QueryTypes, type Sequelize, type Transaction } from "sequelize"; + +import type { + IProcessAssetInventoryService, + IProcessRepository, + ProcessCatalogContext, + ProcessContext, +} from "../../application/process.repository"; +import { ProcessError } from "../../domain/process-values"; + +import { + PersonProcessPersistenceMapper, + type PersonProcessRevisionRow, + type PersonProcessRow, +} from "./process-persistence.mapper"; +import { ProcessReviewReader } from "./process-review-reader"; +import { + ProcessTemplatePersistenceMapper, + type ProcessTemplateRevisionRow, + type ProcessTemplateRow, +} from "./process-template-persistence.mapper"; +export class SequelizeProcessRepository implements IProcessRepository { + private readonly processMapper = new PersonProcessPersistenceMapper(); + private readonly templateMapper = new ProcessTemplatePersistenceMapper(); + constructor( + private readonly database: Sequelize, + private readonly inventory: IProcessAssetInventoryService, + private readonly resources: IResourceInventoryService + ) {} + private async loadCatalog(transaction: Transaction): Promise { + const templates = await this.database.query( + "SELECT * FROM process_templates", + { transaction, type: QueryTypes.SELECT } + ); + const revisions = await this.database.query( + "SELECT * FROM process_template_revisions", + { transaction, type: QueryTypes.SELECT } + ); + const actors = await this.database.query<{ id: string; name: string }>( + "SELECT id,name FROM users", + { transaction, type: QueryTypes.SELECT } + ); + return { + templates: templates.map((t) => + this.templateMapper.toDomain( + t, + revisions.filter((r) => r.itemId === t.id) + ) + ), + actors: new Map(actors.map((a) => [a.id, a.name])), + }; + } + private async load( + company: UniqueID, + transaction: Transaction, + write: boolean + ): Promise> { + const companyId = company.toPrimitive(); + const parent = await this.database.query<{ id: string }>( + `SELECT id FROM companies WHERE id=:companyId${write ? " FOR UPDATE" : ""}`, + { replacements: { companyId }, transaction, type: QueryTypes.SELECT } + ); + if (!parent.length) { + return Result.fail(new ProcessError("COMPANY_NOT_FOUND")); + } + const catalog = await this.loadCatalog(transaction); + const rows = await this.database.query( + "SELECT * FROM person_processes WHERE companyId=:companyId", + { replacements: { companyId }, transaction, type: QueryTypes.SELECT } + ); + const revisions = await this.database.query( + "SELECT r.* FROM person_process_revisions r JOIN person_processes p ON p.id=r.itemId WHERE p.companyId=:companyId", + { replacements: { companyId }, transaction, type: QueryTypes.SELECT } + ); + const persons = await this.database.query<{ id: string; name: string }>( + "SELECT id,name FROM company_contacts WHERE companyId=:companyId", + { replacements: { companyId }, transaction, type: QueryTypes.SELECT } + ); + const people = persons.map((p) => { + const id = UniqueID.create(p.id); + if (!id.isSuccess) { + throw new Error("Persona de proceso inconsistente."); + } + return id.data; + }); + const processes = rows.map((p) => + this.processMapper.toDomain( + p, + revisions.filter((r) => r.itemId === p.id) + ) + ); + if ( + processes.some( + (p) => + !( + p.companyId.equals(company) && + people.some((id) => id.equals(p.values.personId)) && + catalog.templates.some((t) => t.id.equals(p.values.templateId)) + ) + ) + ) { + throw new Error("Ámbito de proceso inconsistente."); + } + const reviews = await new ProcessReviewReader(this.database).load( + companyId, + persons, + transaction, + await this.inventory.list(company), + await this.resources.list(company) + ); + return Result.ok({ ...catalog, people, processes, reviews }); + } + catalog() { + return this.database.transaction(async (t) => Result.ok(await this.loadCatalog(t))); + } + get(company: UniqueID) { + return this.database.transaction((t) => this.load(company, t, false)); + } + saveTemplate(action: Parameters[0]) { + return this.database.transaction(async (transaction) => { + await this.database.query("SELECT id FROM process_catalog_lock WHERE id=1 FOR UPDATE", { + transaction, + type: QueryTypes.SELECT, + }); + const context = await this.loadCatalog(transaction); + const candidate = action(context); + if (!candidate.isSuccess) { + return candidate; + } + const previous = context.templates.find((t) => t.id.equals(candidate.data.id)); + if (previous !== candidate.data) { + const record = this.templateMapper.toPersistence(candidate.data); + await this.database.query( + "INSERT INTO process_templates (id,nameKey,`values`) VALUES (:id,:nameKey,:values) ON DUPLICATE KEY UPDATE nameKey=VALUES(nameKey),`values`=VALUES(`values`)", + { replacements: record.root, transaction } + ); + const known = new Set(previous?.history.map((r) => r.id.toPrimitive())); + for (const r of record.revisions.filter((r) => !known.has(r.id))) { + await this.database.query( + "INSERT INTO process_template_revisions (id,itemId,position,actorId,recordedAt,`values`) VALUES (:id,:itemId,:position,:actorId,:recordedAt,:values)", + { replacements: r, transaction } + ); + } + } + return Result.ok({ template: candidate.data, context }); + }); + } + save(company: UniqueID, action: Parameters[1]) { + return this.database.transaction(async (transaction) => { + const loaded = await this.load(company, transaction, true); + if (!loaded.isSuccess) { + return loaded; + } + const candidate = action(loaded.data); + if (!candidate.isSuccess) { + return candidate; + } + const previous = loaded.data.processes.find((p) => p.id.equals(candidate.data.id)); + if (previous !== candidate.data) { + const record = this.processMapper.toPersistence(candidate.data); + await this.database.query( + "INSERT INTO person_processes (id,companyId,personId,templateId,currentKey,`values`) VALUES (:id,:companyId,:personId,:templateId,:currentKey,:values) ON DUPLICATE KEY UPDATE currentKey=VALUES(currentKey),`values`=VALUES(`values`)", + { replacements: record.root, transaction } + ); + const known = new Set(previous?.history.map((r) => r.id.toPrimitive())); + for (const r of record.revisions.filter((r) => !known.has(r.id))) { + await this.database.query( + "INSERT INTO person_process_revisions (id,itemId,position,actorId,recordedAt,`values`) VALUES (:id,:itemId,:position,:actorId,:recordedAt,:values)", + { replacements: r, transaction } + ); + } + } + return Result.ok({ process: candidate.data, context: loaded.data }); + }); + } +} diff --git a/modules/companies/src/api/module.ts b/modules/companies/src/api/module.ts index bde3975..1edc342 100644 --- a/modules/companies/src/api/module.ts +++ b/modules/companies/src/api/module.ts @@ -12,6 +12,8 @@ import type { IMailInventoryService } from "./application/mail-configuration.rep import { MailConfigurationUseCases } from "./application/mail-configuration.use-cases"; import { MailboxUseCases } from "./application/mailbox.use-cases"; import { PrinterConfigurationUseCases } from "./application/printer-configuration.use-cases"; +import type { IProcessAssetInventoryService } from "./application/process.repository"; +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 { IWorkProfileInventoryService } from "./application/work-profile.repository"; @@ -24,6 +26,7 @@ import { registerFolderMappingRoutes } from "./infrastructure/http/folder-mappin import { registerMailRoutes } from "./infrastructure/http/mail-configuration.routes"; import { registerMailboxRoutes } from "./infrastructure/http/mailbox.routes"; import { registerPrinterConfigurationRoutes } from "./infrastructure/http/printer-configuration.routes"; +import { registerProcessRoutes } from "./infrastructure/http/process.routes"; import { registerRemoteConnectionRoutes } from "./infrastructure/http/remote-connection.routes"; import { registerTeleworkAuthorizationRoutes } from "./infrastructure/http/telework-authorization.routes"; import { registerWorkProfileRoutes } from "./infrastructure/http/work-profile.routes"; @@ -43,6 +46,7 @@ import { migrateCompanies } from "./infrastructure/persistence/migrate-companies import { migratePeople } from "./infrastructure/persistence/migrate-people"; import { initializePeopleModels } from "./infrastructure/persistence/people.model"; import { migratePrinterConfigurations } from "./infrastructure/persistence/printer-configuration-migration"; +import { migrateProcesses } from "./infrastructure/persistence/process-migration"; import { migrateRemoteConnections } from "./infrastructure/persistence/remote-connection-migration"; import { SequelizeApplicationConfigurationRepository } from "./infrastructure/persistence/sequelize-application-configuration.repository"; import { SequelizeCertificateConfigurationRepository } from "./infrastructure/persistence/sequelize-certificate-configuration.repository"; @@ -51,6 +55,7 @@ import { SequelizeFolderMappingRepository } from "./infrastructure/persistence/s 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"; +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 { SequelizeWorkProfileRepository } from "./infrastructure/persistence/sequelize-work-profile.repository"; @@ -78,9 +83,13 @@ export async function createCompanyModule( const useCases = new CompanyUseCases(repository, new CompanyInputMapper()); return { initializeProfiles: async ( - inventory: IWorkProfileInventoryService, + inventory: IWorkProfileInventoryService & IProcessAssetInventoryService, resources: IResourceInventoryService ) => { + await migrateProcesses(database); + const processes = new ProcessUseCases( + new SequelizeProcessRepository(database, inventory, resources) + ); await migrateRemoteConnections(database); const remote = new RemoteConnectionUseCases( new SequelizeRemoteConnectionRepository(database, inventory, resources) @@ -107,6 +116,7 @@ export async function createCompanyModule( ); return { register: (app: Parameters[0]) => { + registerProcessRoutes(app, processes, authenticate); registerRemoteConnectionRoutes(app, remote, authenticate); registerWorkProfileRoutes(app, profiles, authenticate); registerFolderMappingRoutes(app, folders, authenticate); diff --git a/modules/companies/src/common/index.ts b/modules/companies/src/common/index.ts index 4a4626e..d6554e9 100644 --- a/modules/companies/src/common/index.ts +++ b/modules/companies/src/common/index.ts @@ -11,6 +11,7 @@ export * from "./folder-mappings"; export * from "./mail-configurations"; export * from "./mailboxes"; export * from "./printer-configurations"; +export * from "./processes"; export * from "./remote-connections"; export * from "./telework-authorizations"; export * from "./work-profiles"; diff --git a/modules/companies/src/common/processes.ts b/modules/companies/src/common/processes.ts new file mode 100644 index 0000000..6210731 --- /dev/null +++ b/modules/companies/src/common/processes.ts @@ -0,0 +1,52 @@ +import { z } from "zod/v4"; + +import { + ProcessReferenceSchema, + ProcessTemplateInputSchema, + ProcessValuesSchema, +} from "../api/domain/process-values"; + +export type { + ProcessReferenceInput, + ProcessTaskInput, + ProcessTemplateInput, + StartProcessInput, + UpdateProcessInput, +} from "../api/domain/process-values"; +export { + ProcessReferenceSchema, + ProcessTemplateInputSchema, + ProcessValuesSchema, + StartProcessInputSchema, + UpdateProcessInputSchema, +} from "../api/domain/process-values"; + +const audit = { + id: z.uuid(), + actorId: z.uuid().nullable(), + actorName: z.string().min(1), + recordedAt: z.iso.datetime(), +}; +export const ProcessTemplateSchema = z.strictObject({ + id: z.uuid(), + values: ProcessTemplateInputSchema, + history: z.array(z.strictObject({ ...audit, values: ProcessTemplateInputSchema })).min(1), +}); +export const PersonProcessSchema = z.strictObject({ + id: z.uuid(), + companyId: z.uuid(), + values: ProcessValuesSchema, + history: z.array(z.strictObject({ ...audit, values: ProcessValuesSchema })).min(1), + review: z.array(ProcessReferenceSchema), +}); +export const ProcessTemplatesResponseSchema = z.strictObject({ + templates: z.array(ProcessTemplateSchema), +}); +export const ProcessTemplateResponseSchema = z.strictObject({ template: ProcessTemplateSchema }); +export const ProcessesResponseSchema = z.strictObject({ processes: z.array(PersonProcessSchema) }); +export const ProcessResponseSchema = z.strictObject({ process: PersonProcessSchema }); +export const ProcessReviewResponseSchema = z.strictObject({ + review: z.array(ProcessReferenceSchema), +}); +export type ProcessTemplateDTO = z.infer; +export type PersonProcessDTO = z.infer; diff --git a/modules/companies/src/web/components/company-detail.tsx b/modules/companies/src/web/components/company-detail.tsx index 9349063..00fab7f 100644 --- a/modules/companies/src/web/components/company-detail.tsx +++ b/modules/companies/src/web/components/company-detail.tsx @@ -27,6 +27,7 @@ import { FolderMappingsPage } from "../folders/folder-mappings-page"; import { MailConfigurationsPage } from "../mail/mail-configurations-page"; import { MailboxRelations } from "../mail/mailbox-relations"; import { PrinterConfigurationsPage } from "../printers/printer-configurations-page"; +import { ProcessesPage } from "../processes/processes-page"; import { WorkProfilesPage } from "../profiles/work-profiles-page"; import { RemoteConnectionsPage } from "../remote/remote-connections-page"; import { TeleworkAuthorizationsPage } from "../telework/telework-authorizations-page"; @@ -112,6 +113,7 @@ function SiteSection({ company, csrfToken }: { company: CompanyDTO; csrfToken: s ); } type CompanyTab = + | "processes" | "remote" | "telework" | "people" @@ -222,6 +224,7 @@ export function CompanyDetail({ ["profiles", t("profiles.title"), UsersIcon], ["telework", t("telework.title"), KeyRoundIcon], ["remote", t("remote.title"), KeyRoundIcon], + ["processes", t("processes.title"), LayoutTemplateIcon], ["people", `${t("people")} (${company.people.length})`, UsersIcon], [ "departments", @@ -271,6 +274,8 @@ function CompanySection({ tab: CompanyTab; }) { switch (tab) { + case "processes": + return ; case "remote": return ; case "telework": diff --git a/modules/companies/src/web/i18n.ts b/modules/companies/src/web/i18n.ts index 4c3b0d9..95bdcc4 100644 --- a/modules/companies/src/web/i18n.ts +++ b/modules/companies/src/web/i18n.ts @@ -4,12 +4,14 @@ import { foldersEnglish, foldersSpanish } from "./folders/folder-i18n"; import { mailEnglish, mailSpanish } from "./mail/mail-i18n"; import { mailboxEnglish, mailboxSpanish } from "./mail/mailbox-i18n"; import { printersEnglish, printersSpanish } from "./printers/printer-i18n"; +import { processesEnglish, processesSpanish } from "./processes/process-i18n"; import { environmentEnglish, environmentSpanish } from "./profiles/environment-i18n"; import { profilesEnglish, profilesSpanish } from "./profiles/profile-i18n"; import { remoteEnglish, remoteSpanish } from "./remote/remote-i18n"; import { teleworkEnglish, teleworkSpanish } from "./telework/telework-i18n"; export const companiesSpanish = { + processes: processesSpanish, remote: remoteSpanish, telework: teleworkSpanish, environment: environmentSpanish, @@ -134,6 +136,7 @@ export const companiesSpanish = { }, }; export const companiesEnglish = { + processes: processesEnglish, remote: remoteEnglish, telework: teleworkEnglish, environment: environmentEnglish, diff --git a/modules/companies/src/web/mail/mail-i18n.ts b/modules/companies/src/web/mail/mail-i18n.ts index a3033d6..4f954b3 100644 --- a/modules/companies/src/web/mail/mail-i18n.ts +++ b/modules/companies/src/web/mail/mail-i18n.ts @@ -1,7 +1,7 @@ export const mailSpanish = { title: "Correos en dispositivos", new: "Configurar correo", - profile: "Perfil de trabajo", + profile: "Perfil de usuario", legacy: "Registro anterior pendiente de vincular a buzón y perfil.", legacyLink: "Selecciona el perfil de esta persona en este dispositivo y un buzón con la misma dirección. Se conservará el historial anterior:", @@ -76,7 +76,7 @@ export const mailSpanish = { export const mailEnglish = { title: "Email on devices", new: "Configure email", - profile: "Work profile", + profile: "User profile", legacy: "Previous record awaiting mailbox and profile linking.", legacyLink: "Select this person’s profile on this device and a mailbox with the same address. Previous history will be retained:", diff --git a/modules/companies/src/web/processes/process-api.ts b/modules/companies/src/web/processes/process-api.ts new file mode 100644 index 0000000..0bf927d --- /dev/null +++ b/modules/companies/src/web/processes/process-api.ts @@ -0,0 +1,64 @@ +import { z } from "zod/v4"; + +import { + ProcessResponseSchema, + ProcessReviewResponseSchema, + type ProcessTemplateInput, + ProcessTemplateResponseSchema, + ProcessTemplatesResponseSchema, + ProcessesResponseSchema, + type StartProcessInput, + type UpdateProcessInput, +} from "../../common/processes"; +export const processKeys = { + company: (id: string) => ["person-processes", id], + catalog: () => ["process-templates"], + review: (company: string, person: string) => ["process-review", company, person], +}; +export class ProcessApiError extends Error { + constructor(public readonly code: string) { + super(code); + } +} +async function request(path: string, body?: unknown, token?: string) { + const response = await fetch(`/api/processes/${path}`, { + method: body === undefined ? "GET" : "POST", + credentials: "same-origin", + headers: + body === undefined ? {} : { "content-type": "application/json", "x-csrf-token": token ?? "" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + const data: unknown = await response.json(); + if (!response.ok) { + const error = z.object({ code: z.string() }).safeParse(data); + throw new ProcessApiError(error.success ? error.data.code : "INTERNAL_ERROR"); + } + return data; +} +export const processApi = { + async catalog() { + return ProcessTemplatesResponseSchema.parse(await request("templates")); + }, + async saveTemplate(values: ProcessTemplateInput, token: string, id?: string) { + return ProcessTemplateResponseSchema.parse( + await request(`templates${id ? `/${id}/update` : ""}`, { values }, token) + ); + }, + async get(company: string) { + return ProcessesResponseSchema.parse(await request(company)); + }, + async review(company: string, person: string) { + return ProcessReviewResponseSchema.parse(await request(`${company}/people/${person}/review`)); + }, + async start(company: string, values: StartProcessInput, token: string) { + return ProcessResponseSchema.parse(await request(company, { values }, token)); + }, + async update(company: string, id: string, values: UpdateProcessInput, token: string) { + return ProcessResponseSchema.parse(await request(`${company}/${id}/update`, { values }, token)); + }, + async refresh(company: string, id: string, revisionId: string, token: string) { + return ProcessResponseSchema.parse( + await request(`${company}/${id}/refresh`, { revisionId }, token) + ); + }, +}; diff --git a/modules/companies/src/web/processes/process-detail.tsx b/modules/companies/src/web/processes/process-detail.tsx new file mode 100644 index 0000000..27fb106 --- /dev/null +++ b/modules/companies/src/web/processes/process-detail.tsx @@ -0,0 +1,154 @@ +import { Button } from "@repo/shadcn-ui/components/button"; +import { Card, CardContent } from "@repo/shadcn-ui/components/card"; +import { FormDialog } from "@repo/shadcn-ui/components/form-dialog"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import type { PersonProcessDTO } from "../../common/processes"; + +import { processApi, processKeys } from "./process-api"; +import { ProcessEditForm } from "./process-edit-form"; +import { ProcessFeedback } from "./process-feedback"; +import { processReferenceTitle, processTaskTitle } from "./process-labels"; +export function ProcessDetail({ + process, + person, + csrfToken, +}: { + process: PersonProcessDTO; + person: string; + csrfToken: string; +}) { + const { t, i18n } = useTranslation("companies"); + const [edit, setEdit] = useState(false); + const client = useQueryClient(); + const refresh = useMutation({ + mutationFn: () => + processApi.refresh( + process.companyId, + process.id, + process.history.at(-1)?.id ?? "", + csrfToken + ), + onSuccess: async () => { + await client.invalidateQueries({ queryKey: processKeys.company(process.companyId) }); + }, + }); + const open = process.values.state === "open"; + const resolved = process.values.tasks.filter((task) => task.status !== "pending").length; + return ( +
+
+
+

{process.values.name}

+

+ {person} · {t(`processes.${process.values.kind}`)} ·{" "} + {t(`processes.${process.values.state}`)} +

+

+ {t("processes.progress", { resolved, total: process.values.tasks.length })} +

+
+ {open && ( +
+ {process.values.kind !== "onboarding" && ( + + )} + +
+ )} +
+

{t("processes.resolutionHelp")}

+ + {process.values.notes &&

{process.values.notes}

} + + + {process.values.tasks.map((task) => { + const recorded = Boolean( + task.reference && + process.review.some( + (ref) => ref.kind === task.reference?.kind && ref.id === task.reference?.id + ) + ); + return ( +
+

{processTaskTitle(task, t)}

+

{t(`processes.${task.status}`)}

+ {task.reference && ( +

+ {t(recorded ? "processes.stillRecorded" : "processes.notInReview")} +

+ )} + {task.notes &&

{task.notes}

} +
+ ); + })} +
+
+
+ + {t("processes.currentReview", { count: process.review.length })} + +

{t("processes.currentReviewHelp")}

+
    + {process.review.map((ref) => ( +
  1. {processReferenceTitle(ref, t)}
  2. + ))} +
+
+
+ {t("processes.history")} +
    + {[...process.history].reverse().map((r) => ( +
  1. +

    + {new Intl.DateTimeFormat(i18n.language, { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(r.recordedAt))}{" "} + · {r.actorName} +

    +

    + {r.values.name} · {t(`processes.${r.values.state}`)} +

    + {r.values.notes &&

    {r.values.notes}

    } +
      + {r.values.tasks.map((task) => ( +
    1. + {processTaskTitle(task, t)} · {t(`processes.${task.status}`)} + {task.notes &&

      {task.notes}

      } +
    2. + ))} +
    +
  2. + ))} +
+
+ + {edit && ( + setEdit(false)} process={process} /> + )} + +
+ ); +} diff --git a/modules/companies/src/web/processes/process-edit-form.tsx b/modules/companies/src/web/processes/process-edit-form.tsx new file mode 100644 index 0000000..5b015e0 --- /dev/null +++ b/modules/companies/src/web/processes/process-edit-form.tsx @@ -0,0 +1,113 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button } from "@repo/shadcn-ui/components/button"; +import { FormActions } from "@repo/shadcn-ui/components/form-dialog"; +import { Input } from "@repo/shadcn-ui/components/input"; +import { Label } from "@repo/shadcn-ui/components/label"; +import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action"; +import { Textarea } from "@repo/shadcn-ui/components/textarea"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useId, useRef } from "react"; +import { useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; + +import { + type PersonProcessDTO, + type UpdateProcessInput, + UpdateProcessInputSchema, +} from "../../common/processes"; + +import { processApi, processKeys } from "./process-api"; +import { ProcessFeedback } from "./process-feedback"; +import { ProcessHelp } from "./process-help"; +import { ProcessTaskField } from "./process-task-field"; +export function ProcessEditForm({ + process, + csrfToken, + onSaved, +}: { + process: PersonProcessDTO; + csrfToken: string; + onSaved: () => void; +}) { + const { t } = useTranslation("companies"); + const prefix = useId(); + const client = useQueryClient(); + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + const form = useForm({ + resolver: zodResolver(UpdateProcessInputSchema), + defaultValues: { + name: process.values.name, + state: process.values.state, + notes: process.values.notes, + revisionId: process.history.at(-1)?.id ?? "", + tasks: process.values.tasks.map((task) => ({ + id: task.id, + status: task.status, + notes: task.notes, + })), + }, + }); + const mutation = useMutation({ + mutationFn: (input: UpdateProcessInput) => + processApi.update(process.companyId, process.id, input, csrfToken), + onSuccess: async () => { + await client.invalidateQueries({ queryKey: processKeys.company(process.companyId) }); + if (mounted.current) { + onSaved(); + } + }, + }); + return ( +
mutation.mutate(v))}> +
+
+ + +
+
+ + { + const parsed = UpdateProcessInputSchema.shape.state.safeParse(value); + if (parsed.success) { + form.setValue("state", parsed.data); + } + }} + options={["open", "completed", "cancelled"].map((value) => ({ + value, + label: t(`processes.${value}`), + }))} + placeholder={t("mail.choose")} + value={form.watch("state")} + /> +
+
+ +