feat: add process management features including feedback, help, and templates

- Implemented ProcessFeedback component for error handling in process forms.
- Created ProcessHelp component to provide contextual assistance for users.
- Added internationalization support for process-related strings in Spanish and English.
- Developed ProcessStartForm for initiating new processes with person and template selection.
- Introduced ProcessTemplateForm for creating and editing process templates.
- Built ProcessTemplatesPage to display and manage process templates.
- Enhanced ProcessesPage to list current processes and allow for creation and detail viewing.
- Added unit tests for various components to ensure functionality and correctness.
This commit is contained in:
David Arranz 2026-10-07 09:34:18 +02:00
parent d5eafe6f85
commit dffb4b2794
58 changed files with 4224 additions and 26 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,7 +10,7 @@
},
"scripts": {
"typecheck": "tsc -p tsconfig.json",
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/components/companies-page.test.tsx src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/api/domain/mailbox.test.ts src/web/access/access.test.tsx src/web/mail/mail.test.tsx src/api/domain/work-profile.test.ts src/web/profiles/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printers/printer.test.tsx src/api/domain/application-configuration.test.ts src/web/applications/application.test.tsx src/api/domain/certificate-configuration.test.ts src/web/certificates/certificate.test.tsx src/web/profiles/work-environment.test.tsx src/api/domain/telework-authorization.test.ts src/web/telework/telework.test.tsx src/api/domain/remote-connection.test.ts src/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:*",

View File

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

View File

@ -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<AccessDefinition, { kind: "account_person" }>,
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 [];
});
}

View File

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

View File

@ -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<string, string>;
};
export type ProcessContext = ProcessCatalogContext & {
processes: readonly PersonProcess[];
people: readonly UniqueID[];
reviews: ReadonlyMap<string, readonly ProcessReferenceInput[]>;
};
export interface IProcessRepository {
catalog(): Promise<Result<ProcessCatalogContext, ProcessError>>;
saveTemplate(
action: (context: ProcessCatalogContext) => Result<ProcessTemplate, ProcessError>
): Promise<Result<{ template: ProcessTemplate; context: ProcessCatalogContext }, ProcessError>>;
get(company: UniqueID): Promise<Result<ProcessContext, ProcessError>>;
save(
company: UniqueID,
action: (context: ProcessContext) => Result<PersonProcess, ProcessError>
): Promise<Result<{ process: PersonProcess; context: ProcessContext }, ProcessError>>;
}
export interface IProcessAssetInventoryService {
list(
company: UniqueID
): Promise<
readonly { id: UniqueID; name: string; personId: string | null; available: boolean }[]
>;
}

View File

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

View File

@ -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<UniqueID>; recordedAt: string }>;
export class PersonProcessRevision extends DomainEntity<Props> {
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;
}
}

View File

@ -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<ProcessReferenceInput["sourceKind"]>
) {
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<string>;
reference: Maybe<ProcessReferenceValues>;
};
function mapped(value: Omit<ProcessTaskInput, "id">): 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<Values> {
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
);
}
}

View File

@ -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<UniqueID>;
recordedAt: string;
}>;
export class ProcessTemplateRevision extends DomainEntity<Props> {
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;
}
}

View File

@ -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<Props> {
private constructor(props: Props, id?: UniqueID) {
super(Object.freeze({ ...props, history: Object.freeze([...props.history]) }), id);
}
static create(
values: ProcessTemplateValues,
actor: Maybe<UniqueID>,
recordedAt: string
): Result<ProcessTemplate, ProcessError> {
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<ProcessTemplate, ProcessError> {
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
)
);
}
}

View File

@ -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<typeof ProcessTemplateInputSchema>;
export type ProcessValuesInput = z.infer<typeof ProcessValuesSchema>;
export type ProcessTaskInput = z.infer<typeof ProcessTaskSchema>;
export type ProcessReferenceInput = z.infer<typeof ProcessReferenceSchema>;
export type StartProcessInput = z.infer<typeof StartProcessInputSchema>;
export type UpdateProcessInput = z.infer<typeof UpdateProcessInputSchema>;
export class ProcessError extends Error {
constructor(public readonly code: string) {
super(code);
}
}

View File

@ -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<Props> {
private constructor(props: Props, id?: UniqueID) {
super(Object.freeze({ ...props, history: Object.freeze([...props.history]) }), id);
}
static create(
companyId: UniqueID,
values: ProcessValues,
actor: Maybe<UniqueID>,
recordedAt: string
): Result<PersonProcess, ProcessError> {
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<PersonProcess, ProcessError> {
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<PersonProcess, ProcessError> {
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
)
);
}
}

View File

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

View File

@ -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<string>,
public readonly instructions: readonly Name[]
) {
Object.freeze(this);
}
static create(input: ProcessTemplateInput): Result<ProcessTemplateValues, ProcessError> {
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()),
};
}
}

View File

@ -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<string>,
private readonly data: Readonly<Omit<ProcessValuesInput, "tasks">>,
public readonly tasks: readonly ProcessTask[]
) {
Object.freeze(this);
}
static create(input: ProcessValuesInput): Result<ProcessValues, ProcessError> {
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()),
};
}
}

View File

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

View File

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

View File

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

View File

@ -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<string, string>,
resourceNames: ReadonlyMap<string, string>
) {
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<Row>(
`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<string, string>,
resourceNames: ReadonlyMap<string, string>
) {
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<string, string>,
resourceNames: ReadonlyMap<string, string>
) {
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<ReturnType<IProcessAssetInventoryService["list"]>>,
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;
}
}

View File

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

View File

@ -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<ProcessCatalogContext> {
const templates = await this.database.query<ProcessTemplateRow>(
"SELECT * FROM process_templates",
{ transaction, type: QueryTypes.SELECT }
);
const revisions = await this.database.query<ProcessTemplateRevisionRow>(
"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<Result<ProcessContext, ProcessError>> {
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<PersonProcessRow>(
"SELECT * FROM person_processes WHERE companyId=:companyId",
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
);
const revisions = await this.database.query<PersonProcessRevisionRow>(
"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<IProcessRepository["saveTemplate"]>[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<IProcessRepository["save"]>[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 });
});
}
}

View File

@ -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<typeof registerWorkProfileRoutes>[0]) => {
registerProcessRoutes(app, processes, authenticate);
registerRemoteConnectionRoutes(app, remote, authenticate);
registerWorkProfileRoutes(app, profiles, authenticate);
registerFolderMappingRoutes(app, folders, authenticate);

View File

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

View File

@ -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<typeof ProcessTemplateSchema>;
export type PersonProcessDTO = z.infer<typeof PersonProcessSchema>;

View File

@ -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 <ProcessesPage company={company} csrfToken={csrfToken} />;
case "remote":
return <RemoteConnectionsPage company={company} csrfToken={csrfToken} />;
case "telework":

View File

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

View File

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

View File

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

View File

@ -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 (
<div className="space-y-5">
<header className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-xl font-semibold">{process.values.name}</h2>
<p>
{person} · {t(`processes.${process.values.kind}`)} ·{" "}
{t(`processes.${process.values.state}`)}
</p>
<p className="text-sm text-muted-foreground">
{t("processes.progress", { resolved, total: process.values.tasks.length })}
</p>
</div>
{open && (
<div className="flex gap-2">
{process.values.kind !== "onboarding" && (
<Button
disabled={refresh.isPending}
onClick={() => refresh.mutate()}
variant="outline"
>
{t("processes.refresh")}
</Button>
)}
<Button disabled={refresh.isPending} onClick={() => setEdit(true)}>
{t("processes.update")}
</Button>
</div>
)}
</header>
<p className="text-sm text-muted-foreground">{t("processes.resolutionHelp")}</p>
<ProcessFeedback error={refresh.error} />
{process.values.notes && <p className="whitespace-pre-wrap">{process.values.notes}</p>}
<Card>
<CardContent className="divide-y pt-3">
{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 (
<section className="space-y-2 py-3" key={task.id}>
<h3 className="font-medium">{processTaskTitle(task, t)}</h3>
<p className="text-sm">{t(`processes.${task.status}`)}</p>
{task.reference && (
<p
className={
recorded ? "text-sm text-amber-700" : "text-sm text-muted-foreground"
}
>
{t(recorded ? "processes.stillRecorded" : "processes.notInReview")}
</p>
)}
{task.notes && <p className="text-sm whitespace-pre-wrap">{task.notes}</p>}
</section>
);
})}
</CardContent>
</Card>
<details className="rounded-md border p-4">
<summary className="cursor-pointer font-medium">
{t("processes.currentReview", { count: process.review.length })}
</summary>
<p className="text-sm mt-2">{t("processes.currentReviewHelp")}</p>
<ol className="list-disc pl-5 text-sm mt-2">
{process.review.map((ref) => (
<li key={`${ref.kind}:${ref.id}`}>{processReferenceTitle(ref, t)}</li>
))}
</ol>
</details>
<details className="rounded-md border p-4">
<summary className="cursor-pointer font-medium">{t("processes.history")}</summary>
<ol className="space-y-3 mt-3">
{[...process.history].reverse().map((r) => (
<li className="rounded border p-3 text-sm" key={r.id}>
<p className="font-medium">
{new Intl.DateTimeFormat(i18n.language, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(r.recordedAt))}{" "}
· {r.actorName}
</p>
<p>
{r.values.name} · {t(`processes.${r.values.state}`)}
</p>
{r.values.notes && <p className="whitespace-pre-wrap">{r.values.notes}</p>}
<ol className="list-decimal pl-5 mt-2">
{r.values.tasks.map((task) => (
<li key={task.id}>
{processTaskTitle(task, t)} · {t(`processes.${task.status}`)}
{task.notes && <p className="whitespace-pre-wrap">{task.notes}</p>}
</li>
))}
</ol>
</li>
))}
</ol>
</details>
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={setEdit}
open={edit}
title={t("processes.update")}
wide
>
{edit && (
<ProcessEditForm csrfToken={csrfToken} onSaved={() => setEdit(false)} process={process} />
)}
</FormDialog>
</div>
);
}

View File

@ -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<UpdateProcessInput>({
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 (
<form className="space-y-5" onSubmit={form.handleSubmit((v) => mutation.mutate(v))}>
<fieldset className="space-y-4" disabled={mutation.isPending}>
<div className="space-y-2">
<Label htmlFor={`${prefix}-name`}>{t("name")}</Label>
<Input id={`${prefix}-name`} maxLength={160} required {...form.register("name")} />
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-state`}>{t("mail.state")}</Label>
<SelectWithAction
id={`${prefix}-state`}
label={t("mail.state")}
onValueChange={(value) => {
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")}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-notes`}>{t("mail.notes")}</Label>
<Textarea id={`${prefix}-notes`} maxLength={4000} rows={3} {...form.register("notes")} />
</div>
<p className="text-sm text-muted-foreground">{t("processes.resolutionHelp")}</p>
{process.values.tasks.map((task, index) => (
<ProcessTaskField form={form} index={index} key={task.id} task={task} />
))}
</fieldset>
<ProcessFeedback
error={mutation.error}
invalid={Object.keys(form.formState.errors).length > 0}
/>
<ProcessHelp />
<FormActions>
<Button disabled={mutation.isPending} type="submit">
{t(mutation.isPending ? "saving" : "save")}
</Button>
</FormActions>
</form>
);
}

View File

@ -0,0 +1,25 @@
import { useTranslation } from "react-i18next";
import { ProcessApiError } from "./process-api";
export function ProcessFeedback({
error,
invalid = false,
}: {
error?: unknown;
invalid?: boolean;
}) {
const { t } = useTranslation("companies");
if (!(error || invalid)) {
return null;
}
const code = invalid
? "INVALID_REQUEST"
: error instanceof ProcessApiError
? error.code
: "INTERNAL_ERROR";
return (
<p className="text-sm text-destructive" role="alert">
{t(`processes.errors.${code}`, { defaultValue: t("processes.errors.INTERNAL_ERROR") })}
</p>
);
}

View File

@ -0,0 +1,16 @@
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
import { useTranslation } from "react-i18next";
export function ProcessHelp({ template = false }: { template?: boolean }) {
const { t } = useTranslation("companies");
const prefix = template ? "processes.templateHelp" : "processes.help";
return (
<ContextHelp
description={t(`${prefix}.description`)}
examples={t(`${prefix}.examples`)}
examplesLabel={t("mail.examplesLabel")}
guidance={t(`${prefix}.guidance`)}
scopeNote={t(`${prefix}.scope`)}
title={t(`${prefix}.title`)}
/>
);
}

View File

@ -0,0 +1,193 @@
export const processesSpanish = {
title: "Procesos de personas",
templates: "Plantillas de procesos",
"new": "Nuevo proceso",
template: "Plantilla de proceso",
newTemplate: "Nueva plantilla de proceso",
editTemplate: "Editar plantilla de proceso",
globalTemplates:
"Estas plantillas son comunes a todas las compañías. Los cambios solo afectan a los procesos que se inicien después.",
kind: "Tipo de proceso",
onboarding: "Alta",
change: "Cambio",
offboarding: "Salida",
instructions: "Tareas de la plantilla",
instructionsHelp:
"Una tarea por línea, hasta 100 tareas de 160 caracteres. No repitas una tarea.",
copy: "Crear copia",
copyOf: "Copia de",
history: "Historial",
back: "Volver a procesos",
search: "Buscar proceso, persona o tipo…",
showClosed: "Mostrar también los completados y cancelados",
empty: "Todavía no hay procesos registrados.",
openDetail: "Abrir proceso",
update: "Actualizar seguimiento",
refresh: "Añadir nuevas revisiones",
progress: "{{resolved}} de {{total}} tareas resueltas",
reviewCount: "Se añadirán {{count}} revisiones de los registros actuales de esta persona.",
open: "En curso",
completed: "Completado",
cancelled: "Cancelado",
pending: "Pendiente",
done: "Revisada",
not_applicable: "No procede",
taskNotes: "Resultado o notas de la tarea",
resolutionHelp:
"Revisada significa que has comprobado la tarea. Documenta si se retira, conserva o reasigna el acceso. No cambia cuentas, permisos, configuraciones ni activos.",
stillRecorded: "El elemento sigue en la lista de revisión actual de la persona.",
notInReview: "El elemento ya no aparece en la revisión actual; verifica el resultado real.",
currentReview: "Registros actuales para revisar ({{count}})",
currentReviewHelp:
"Esta lista se consulta de nuevo en el servidor. Puede contener tareas ya revisadas. Las revisiones nuevas se añaden al proceso con el botón de la cabecera; las anteriores conservan su resultado.",
reference: {
profile: "Perfil de usuario",
asset: "Activo asignado",
account: "Relación con cuenta",
membership: "Pertenencia a grupo",
need: "Necesidad de recurso",
mailbox_use: "Uso de correo",
mail: "Correo configurado",
folder: "Carpeta configurada",
printer: "Impresora configurada",
application: "Aplicación configurada",
certificate: "Certificado configurado",
telework: "Autorización de teletrabajo",
remote: "Acceso remoto",
},
help: {
title: "¿Para qué sirve este proceso?",
description: "Organiza las comprobaciones del alta, cambio o salida de una persona.",
scope:
"El proceso pertenece a esta compañía. Copia su plantilla; los cambios posteriores de la plantilla no lo modifican. Para cambio y salida se añaden revisiones de los registros documentados de la persona.",
examples:
"Alta: preparar cuentas y perfil. Cambio: revisar y trasladar configuraciones. Salida: recuperar activos y revisar correo, permisos y acceso desde equipos personales.",
guidance:
"Todas las tareas deben estar revisadas o marcadas No procede para completar el proceso. Los cerrados conservan su historial. Esto documenta el seguimiento; las operaciones sobre cuentas y equipos se realizan en su gestión correspondiente. Las altas rápidas guardadas permanecen si cancelas.",
},
templateHelp: {
title: "¿Para qué sirve esta plantilla?",
description: "Define una lista reutilizable de tareas para altas, cambios o salidas.",
scope:
"Es global y no contiene datos de personas, compañías, cuentas ni equipos concretos. Se copia al iniciar cada proceso.",
examples:
"Comprobar cuentas y permisos. Preparar el perfil. Recuperar el equipo. Revisar acceso desde móviles personales.",
guidance:
"Puedes editar o desactivar plantillas. Desactivar impide iniciar nuevos procesos; los existentes se conservan. Crea una copia si necesitas una variante del procedimiento.",
},
errors: {
INVALID_REQUEST: "Revisa los campos y las tareas.",
PROCESS_TEMPLATE_NOT_FOUND: "No se ha encontrado la plantilla.",
PROCESS_TEMPLATE_INACTIVE: "La plantilla está desactivada.",
PROCESS_TEMPLATE_DUPLICATE: "Ya existe una plantilla con ese nombre.",
PROCESS_PERSON_NOT_FOUND: "Selecciona una persona de esta compañía.",
PROCESS_ALREADY_OPEN: "Esta persona ya tiene un proceso de este tipo en curso.",
PROCESS_NOT_FOUND: "No se ha encontrado el proceso.",
PROCESS_CLOSED: "El proceso cerrado no se puede modificar.",
PROCESS_PENDING_TASKS: "Resuelve las tareas pendientes antes de completar el proceso.",
PROCESS_TASKS_IMMUTABLE: "Las tareas y sus referencias no se pueden sustituir.",
PROCESS_SUBJECT_IMMUTABLE: "La persona y la plantilla inicial no se pueden cambiar.",
PROCESS_CONFLICT:
"Otro usuario ha actualizado el proceso. Cancela el formulario, recarga la ficha y vuelve a editar.",
COMPANY_NOT_FOUND: "No se ha encontrado la compañía.",
INTERNAL_ERROR: "No se ha podido completar la operación. Inténtalo de nuevo.",
FORBIDDEN: "Vuelve a iniciar sesión.",
UNAUTHENTICATED: "Vuelve a iniciar sesión.",
},
};
export const processesEnglish = {
title: "People processes",
templates: "Process templates",
"new": "New process",
template: "Process template",
newTemplate: "New process template",
editTemplate: "Edit process template",
globalTemplates:
"These templates are shared across companies. Changes only affect processes started afterwards.",
kind: "Process type",
onboarding: "Onboarding",
change: "Change",
offboarding: "Offboarding",
instructions: "Template tasks",
instructionsHelp: "One task per line, up to 100 tasks of 160 characters. Do not repeat a task.",
copy: "Create copy",
copyOf: "Copy of",
history: "History",
back: "Back to processes",
search: "Search process, person or type…",
showClosed: "Also show completed and cancelled processes",
empty: "No processes recorded yet.",
openDetail: "Open process",
update: "Update progress",
refresh: "Add new reviews",
progress: "{{resolved}} of {{total}} tasks resolved",
reviewCount: "{{count}} reviews of this person's current records will be added.",
open: "In progress",
completed: "Completed",
cancelled: "Cancelled",
pending: "Pending",
done: "Reviewed",
not_applicable: "Not applicable",
taskNotes: "Task outcome or notes",
resolutionHelp:
"Reviewed means you have checked the task. Document whether access is withdrawn, retained or reassigned. It does not change accounts, permissions, configurations or assets.",
stillRecorded: "This item is still in the person's current review list.",
notInReview: "This item no longer appears in the current review; verify the actual outcome.",
currentReview: "Current records to review ({{count}})",
currentReviewHelp:
"This list is read again on the server and may include tasks already reviewed. Use the header button to add new reviews; existing task outcomes are preserved.",
reference: {
profile: "User profile",
asset: "Assigned asset",
account: "Account relationship",
membership: "Group membership",
need: "Resource need",
mailbox_use: "Mail use",
mail: "Mail configuration",
folder: "Folder configuration",
printer: "Printer configuration",
application: "Application configuration",
certificate: "Certificate configuration",
telework: "Telework authorization",
remote: "Remote access",
},
help: {
title: "What is this process for?",
description: "Organize checks when a person joins, changes or leaves.",
scope:
"This process belongs to this company and copies its template. Later template changes do not alter it. Change and offboarding add reviews of the person's documented records.",
examples:
"Onboarding: prepare accounts and profile. Change: review and transfer configurations. Offboarding: recover assets and review mail, permissions and access from personal devices.",
guidance:
"Every task must be reviewed or marked Not applicable before completing the process. Closed processes retain history. This documents progress; account and device actions are performed in their respective sections. Saved quick additions remain if you cancel.",
},
templateHelp: {
title: "What is this template for?",
description: "Define a reusable checklist for onboarding, changes or offboarding.",
scope:
"It is global and contains no specific people, companies, accounts or device data. It is copied when each process starts.",
examples:
"Check accounts and permissions. Prepare the profile. Recover the device. Review access from personal mobile devices.",
guidance:
"Templates can be edited or disabled. Disabling prevents new processes and preserves existing ones. Create a copy for a different procedure.",
},
errors: {
INVALID_REQUEST: "Check the fields and tasks.",
PROCESS_TEMPLATE_NOT_FOUND: "Template not found.",
PROCESS_TEMPLATE_INACTIVE: "The template is disabled.",
PROCESS_TEMPLATE_DUPLICATE: "A template with this name already exists.",
PROCESS_PERSON_NOT_FOUND: "Select a person from this company.",
PROCESS_ALREADY_OPEN: "This person already has a process of this type in progress.",
PROCESS_NOT_FOUND: "Process not found.",
PROCESS_CLOSED: "A closed process cannot change.",
PROCESS_PENDING_TASKS: "Resolve pending tasks before completing the process.",
PROCESS_TASKS_IMMUTABLE: "Tasks and their references cannot be replaced.",
PROCESS_SUBJECT_IMMUTABLE: "The person and original template cannot change.",
PROCESS_CONFLICT:
"Another user updated the process. Cancel this form, reload the record and edit again.",
COMPANY_NOT_FOUND: "Company not found.",
INTERNAL_ERROR: "The operation could not be completed. Try again.",
FORBIDDEN: "Sign in again.",
UNAUTHENTICATED: "Sign in again.",
},
};

View File

@ -0,0 +1,10 @@
import type { TFunction } from "i18next";
import type { ProcessReferenceInput, ProcessTaskInput } from "../../common/processes";
export function processReferenceTitle(ref: ProcessReferenceInput, t: TFunction) {
const origin = ref.sourceKind ? t(`remote.${ref.sourceKind}`) : "";
return [t(`processes.reference.${ref.kind}`), ref.label, origin].filter(Boolean).join(" · ");
}
export function processTaskTitle(task: ProcessTaskInput, t: TFunction) {
return task.reference ? processReferenceTitle(task.reference, t) : task.title;
}

View File

@ -0,0 +1,174 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@repo/shadcn-ui/components/button";
import { FormActions, FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { Input } from "@repo/shadcn-ui/components/input";
import { Label } from "@repo/shadcn-ui/components/label";
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
import { Textarea } from "@repo/shadcn-ui/components/textarea";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useId, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { CompanyDTO } from "../../common";
import { type StartProcessInput, StartProcessInputSchema } from "../../common/processes";
import { PersonForm } from "../components/person-form";
import { processApi, processKeys } from "./process-api";
import { ProcessFeedback } from "./process-feedback";
import { ProcessHelp } from "./process-help";
import { ProcessTemplateForm } from "./process-template-form";
export function ProcessStartForm({
company,
csrfToken,
onSaved,
}: {
company: CompanyDTO;
csrfToken: string;
onSaved: (id: string) => void;
}) {
const { t } = useTranslation("companies");
const prefix = useId();
const client = useQueryClient();
const [quick, setQuick] = useState<"person" | "template">();
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const query = useQuery({ queryKey: processKeys.catalog(), queryFn: processApi.catalog });
const form = useForm<StartProcessInput>({
resolver: zodResolver(StartProcessInputSchema),
defaultValues: { name: "", personId: "", templateId: "", notes: null },
});
const values = form.watch();
const template = query.data?.templates.find((t) => t.id === values.templateId);
const review = useQuery({
queryKey: processKeys.review(company.id, values.personId),
queryFn: () => processApi.review(company.id, values.personId),
enabled: Boolean(values.personId),
});
const mutation = useMutation({
mutationFn: (input: StartProcessInput) => processApi.start(company.id, input, csrfToken),
onSuccess: async (response) => {
await client.invalidateQueries({ queryKey: processKeys.company(company.id) });
if (mounted.current) {
onSaved(response.process.id);
}
},
});
const onCreated = (id: string) => {
if (mounted.current) {
form.setValue(quick === "person" ? "personId" : "templateId", id);
setQuick(undefined);
}
};
return (
<>
<form className="space-y-5" onSubmit={form.handleSubmit((v) => mutation.mutate(v))}>
<fieldset className="space-y-4" disabled={mutation.isPending}>
<div className="space-y-2">
<Label htmlFor={`${prefix}-name`}>{t("name")}</Label>
<Input id={`${prefix}-name`} maxLength={160} required {...form.register("name")} />
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-person`}>{t("profiles.person")}</Label>
<SelectWithAction
action={{ label: t("newPerson"), onClick: () => setQuick("person") }}
id={`${prefix}-person`}
label={t("profiles.person")}
onValueChange={(id) => form.setValue("personId", id)}
options={company.people.map((p) => ({ value: p.id, label: p.name }))}
placeholder={t("mail.choose")}
required
value={values.personId}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-template`}>{t("processes.template")}</Label>
<SelectWithAction
action={{ label: t("processes.newTemplate"), onClick: () => setQuick("template") }}
disabled={!query.data || query.isError}
id={`${prefix}-template`}
label={t("processes.template")}
onValueChange={(id) => form.setValue("templateId", id)}
options={(query.data?.templates ?? [])
.filter((t) => t.values.active)
.map((t) => ({ value: t.id, label: t.values.name }))}
placeholder={t("mail.choose")}
required
value={values.templateId}
/>
</div>
{template && (
<div className="rounded-md border p-3 text-sm">
<p className="font-medium">{t(`processes.${template.values.kind}`)}</p>
<ol className="list-decimal pl-5">
{template.values.instructions.map((i) => (
<li key={i}>{i}</li>
))}
</ol>
{template.values.kind !== "onboarding" && (
<p className="mt-2">
{review.isPending
? t("loading")
: t("processes.reviewCount", { count: review.data?.review.length ?? 0 })}
</p>
)}
</div>
)}
<div className="space-y-2">
<Label htmlFor={`${prefix}-notes`}>{t("mail.notes")}</Label>
<Textarea
id={`${prefix}-notes`}
maxLength={4000}
rows={3}
{...form.register("notes")}
/>
</div>
</fieldset>
<ProcessFeedback
error={mutation.error ?? query.error ?? review.error}
invalid={Object.keys(form.formState.errors).length > 0}
/>
<ProcessHelp />
<FormActions>
<Button
disabled={
mutation.isPending ||
!query.data ||
query.isError ||
review.isError ||
!template?.values.active
}
type="submit"
>
{t(mutation.isPending ? "saving" : "save")}
</Button>
</FormActions>
</form>
{quick && (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
setQuick(undefined);
}
}}
open
title={t(quick === "person" ? "newPerson" : "processes.newTemplate")}
wide
>
{quick === "person" ? (
<PersonForm companyId={company.id} csrfToken={csrfToken} onSaved={onCreated} />
) : (
<ProcessTemplateForm csrfToken={csrfToken} onSaved={onCreated} />
)}
</FormDialog>
)}
</>
);
}

View File

@ -0,0 +1,64 @@
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 { useId } from "react";
import type { UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next";
import {
type PersonProcessDTO,
ProcessValuesSchema,
type UpdateProcessInput,
} from "../../common/processes";
import { processTaskTitle } from "./process-labels";
export function ProcessTaskField({
index,
task,
form,
}: {
index: number;
task: PersonProcessDTO["values"]["tasks"][number];
form: UseFormReturn<UpdateProcessInput>;
}) {
const { t } = useTranslation("companies");
const prefix = useId();
const value = form.watch(`tasks.${index}.status`);
return (
<section className="rounded-md border p-3 space-y-3">
<div className="space-y-2">
<Label htmlFor={`${prefix}-state`}>{processTaskTitle(task, t)}</Label>
<SelectWithAction
id={`${prefix}-state`}
label={task.title}
onValueChange={(value) => {
const parsed = ProcessValuesSchema.shape.tasks.element.shape.status.safeParse(value);
if (parsed.success) {
form.setValue(`tasks.${index}.status`, parsed.data);
}
}}
options={["pending", "done", "not_applicable"].map((value) => ({
value,
label: t(`processes.${value}`),
}))}
placeholder={t("mail.choose")}
value={value}
/>
</div>
<details>
<summary className="text-sm cursor-pointer">{t("processes.taskNotes")}</summary>
<div className="mt-2">
<Label className="sr-only" htmlFor={`${prefix}-notes`}>
{t("processes.taskNotes")}: {task.title}
</Label>
<Textarea
id={`${prefix}-notes`}
maxLength={4000}
rows={2}
{...form.register(`tasks.${index}.notes`)}
/>
</div>
</details>
</section>
);
}

View File

@ -0,0 +1,164 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@repo/shadcn-ui/components/button";
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
import { Input } from "@repo/shadcn-ui/components/input";
import { Label } from "@repo/shadcn-ui/components/label";
import { 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 { z } from "zod/v4";
import {
type ProcessTemplateDTO,
type ProcessTemplateInput,
ProcessTemplateInputSchema,
} from "../../common/processes";
import { processApi, processKeys } from "./process-api";
import { ProcessFeedback } from "./process-feedback";
import { ProcessHelp } from "./process-help";
const FormSchema = z.strictObject({
name: ProcessTemplateInputSchema.shape.name,
kind: ProcessTemplateInputSchema.shape.kind,
active: ProcessTemplateInputSchema.shape.active,
notes: ProcessTemplateInputSchema.shape.notes,
lines: z.string().refine(
(value) =>
ProcessTemplateInputSchema.shape.instructions.safeParse(
value
.split("\n")
.map((s) => s.trim())
.filter(Boolean)
).success
),
});
type Values = z.infer<typeof FormSchema>;
export function ProcessTemplateForm({
template,
initialValues,
csrfToken,
onSaved,
}: {
template?: ProcessTemplateDTO | undefined;
initialValues?: ProcessTemplateInput | undefined;
csrfToken: string;
onSaved: (id: string) => void;
}) {
const { t } = useTranslation("companies");
const prefix = useId();
const client = useQueryClient();
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const initial = template?.values ?? initialValues;
const form = useForm<Values>({
resolver: zodResolver(FormSchema),
defaultValues: {
name: initial?.name ?? "",
kind: initial?.kind ?? "onboarding",
active: initial?.active ?? true,
notes: initial?.notes ?? null,
lines: initial?.instructions.join("\n") ?? "",
},
});
const mutation = useMutation({
mutationFn: (v: Values) =>
processApi.saveTemplate(
{
name: v.name,
kind: v.kind,
active: v.active,
notes: v.notes,
instructions: v.lines
.split("\n")
.map((s) => s.trim())
.filter(Boolean),
},
csrfToken,
template?.id
),
onSuccess: async (response) => {
await client.invalidateQueries({ queryKey: processKeys.catalog() });
if (mounted.current) {
onSaved(response.template.id);
}
},
});
return (
<form
className="space-y-5"
onSubmit={form.handleSubmit((v) => {
const { lines, ...values } = v;
mutation.mutate({ ...values, lines });
})}
>
<fieldset className="space-y-4" disabled={mutation.isPending}>
<div className="space-y-2">
<Label htmlFor={`${prefix}-name`}>{t("name")}</Label>
<Input id={`${prefix}-name`} maxLength={160} required {...form.register("name")} />
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-kind`}>{t("processes.kind")}</Label>
<SelectWithAction
id={`${prefix}-kind`}
label={t("processes.kind")}
onValueChange={(value) => {
const parsed = ProcessTemplateInputSchema.shape.kind.safeParse(value);
if (parsed.success) {
form.setValue("kind", parsed.data);
}
}}
options={["onboarding", "change", "offboarding"].map((value) => ({
value,
label: t(`processes.${value}`),
}))}
placeholder={t("mail.choose")}
value={form.watch("kind")}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-instructions`}>{t("processes.instructions")}</Label>
<Textarea
id={`${prefix}-instructions`}
maxLength={16100}
required
rows={8}
{...form.register("lines")}
/>
<p className="text-sm text-muted-foreground">{t("processes.instructionsHelp")}</p>
</div>
<div className="flex items-center gap-2">
<Checkbox
checked={form.watch("active")}
id={`${prefix}-active`}
onCheckedChange={(v) => form.setValue("active", v === true)}
/>
<Label htmlFor={`${prefix}-active`}>{t("active")}</Label>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-notes`}>{t("mail.notes")}</Label>
<Textarea id={`${prefix}-notes`} maxLength={4000} rows={3} {...form.register("notes")} />
</div>
</fieldset>
<ProcessFeedback
error={mutation.error}
invalid={Object.keys(form.formState.errors).length > 0}
/>
<ProcessHelp template />
<FormActions>
<Button disabled={mutation.isPending} type="submit">
{t(mutation.isPending ? "saving" : "save")}
</Button>
</FormActions>
</form>
);
}

View File

@ -0,0 +1,130 @@
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 { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { processApi, processKeys } from "./process-api";
import { ProcessFeedback } from "./process-feedback";
import { ProcessTemplateForm } from "./process-template-form";
export function ProcessTemplatesPage({ csrfToken }: { csrfToken: string }) {
const { t } = useTranslation("companies");
const query = useQuery({ queryKey: processKeys.catalog(), queryFn: processApi.catalog });
const [editor, setEditor] = useState<string>();
const [copy, setCopy] = useState<string>();
const templates = query.data?.templates ?? [];
const selected = templates.find((x) => x.id === editor);
const copied = templates.find((x) => x.id === copy);
const close = () => {
setEditor(undefined);
setCopy(undefined);
};
return (
<div className="space-y-5">
<header className="flex items-center justify-between gap-4">
<h2 className="text-xl font-semibold">{t("processes.templates")}</h2>
<Button disabled={!query.data || query.isError} onClick={() => setEditor("new")}>
{t("processes.newTemplate")}
</Button>
</header>
<p className="text-sm text-muted-foreground">{t("processes.globalTemplates")}</p>
{query.isPending && <p role="status">{t("loading")}</p>}
<ProcessFeedback error={query.error} />
{query.isError && (
<Button
onClick={() => {
query.refetch().catch(() => undefined);
}}
variant="outline"
>
{t("retry")}
</Button>
)}
{query.data && !query.isError && (
<Card>
<CardContent className="divide-y pt-3">
{templates.map((template) => (
<section className="space-y-3 py-4" key={template.id}>
<header className="flex flex-wrap justify-between gap-3">
<div>
<h3 className="font-medium">{template.values.name}</h3>
<p className="text-sm text-muted-foreground">
{t(`processes.${template.values.kind}`)} ·{" "}
{t(template.values.active ? "active" : "inactive")}
</p>
</div>
<div className="flex gap-2">
<Button onClick={() => setEditor(template.id)} size="sm" variant="outline">
{t("edit")}
</Button>
<Button
onClick={() => {
setCopy(template.id);
setEditor("new");
}}
size="sm"
variant="outline"
>
{t("processes.copy")}
</Button>
</div>
</header>
<ol className="list-decimal pl-5 text-sm">
{template.values.instructions.map((instruction) => (
<li key={instruction}>{instruction}</li>
))}
</ol>
<details>
<summary className="cursor-pointer text-sm">{t("processes.history")}</summary>
<ol className="space-y-2 mt-2">
{[...template.history].reverse().map((r) => (
<li className="rounded border p-2 text-sm" key={r.id}>
{r.recordedAt} · {r.actorName}
<p>
{r.values.name} · {t(r.values.active ? "active" : "inactive")}
</p>
<ol className="list-decimal pl-5">
{r.values.instructions.map((i) => (
<li key={i}>{i}</li>
))}
</ol>
{r.values.notes && <p className="whitespace-pre-wrap">{r.values.notes}</p>}
</li>
))}
</ol>
</details>
</section>
))}
</CardContent>
</Card>
)}
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
close();
}
}}
open={Boolean(editor)}
title={t(selected ? "processes.editTemplate" : "processes.newTemplate")}
wide
>
{editor && (
<ProcessTemplateForm
csrfToken={csrfToken}
initialValues={
copied
? { ...copied.values, name: `${t("processes.copyOf")} ${copied.values.name}` }
: undefined
}
key={editor + (copy ?? "")}
onSaved={close}
template={selected}
/>
)}
</FormDialog>
</div>
);
}

View File

@ -0,0 +1,156 @@
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 { Input } from "@repo/shadcn-ui/components/input";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { CompanyDTO } from "../../common";
import { processApi, processKeys } from "./process-api";
import { ProcessDetail } from "./process-detail";
import { ProcessFeedback } from "./process-feedback";
import { ProcessStartForm } from "./process-start-form";
import { ProcessTemplatesPage } from "./process-templates-page";
export function ProcessesPage({ company, csrfToken }: { company: CompanyDTO; csrfToken: string }) {
const { t } = useTranslation("companies");
const [templates, setTemplates] = useState(false);
const [create, setCreate] = useState(false);
const [selected, setSelected] = useState<string>();
const [search, setSearch] = useState("");
const [closed, setClosed] = useState(false);
const query = useQuery({
queryKey: processKeys.company(company.id),
queryFn: () => processApi.get(company.id),
});
const person = (id: string) => company.people.find((p) => p.id === id)?.name ?? id;
const processes = query.data?.processes ?? [];
const current = processes.find((p) => p.id === selected);
const filtered = processes.filter(
(p) =>
(closed || p.values.state === "open") &&
[p.values.name, person(p.values.personId), t(`processes.${p.values.kind}`)].some((s) =>
s.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())
)
);
if (templates) {
return (
<div className="space-y-5">
<Button onClick={() => setTemplates(false)} variant="outline">
{t("processes.back")}
</Button>
<ProcessTemplatesPage csrfToken={csrfToken} />
</div>
);
}
return (
<div className="space-y-5">
{selected && (
<Button onClick={() => setSelected(undefined)} variant="outline">
{t("processes.back")}
</Button>
)}
{query.isPending && <p role="status">{t("loading")}</p>}
<ProcessFeedback error={query.error} />
{query.isError && (
<Button
onClick={() => {
query.refetch().catch(() => undefined);
}}
variant="outline"
>
{t("retry")}
</Button>
)}
{current && !query.isError ? (
<ProcessDetail
csrfToken={csrfToken}
person={person(current.values.personId)}
process={current}
/>
) : (
!selected && (
<>
<header className="flex flex-wrap justify-between gap-3">
<h2 className="text-xl font-semibold">{t("processes.title")}</h2>
<div className="flex gap-2">
<Button onClick={() => setTemplates(true)} variant="outline">
{t("processes.templates")}
</Button>
<Button disabled={!query.data || query.isError} onClick={() => setCreate(true)}>
{t("processes.new")}
</Button>
</div>
</header>
<Input
aria-label={t("processes.search")}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("processes.search")}
value={search}
/>
<label className="flex items-center gap-2 text-sm">
<input
checked={closed}
onChange={(e) => setClosed(e.target.checked)}
type="checkbox"
/>
{t("processes.showClosed")}
</label>
{query.data && !query.isError && (
<Card>
<CardContent className="divide-y pt-3">
{!filtered.length && (
<p className="py-4 text-muted-foreground">
{t(processes.length ? "noResults" : "processes.empty")}
</p>
)}
{filtered.map((p) => (
<section className="flex items-start justify-between gap-3 py-4" key={p.id}>
<div>
<h3 className="font-medium">{p.values.name}</h3>
<p>
{person(p.values.personId)} · {t(`processes.${p.values.kind}`)} ·{" "}
{t(`processes.${p.values.state}`)}
</p>
<p className="text-sm text-muted-foreground">
{t("processes.progress", {
resolved: p.values.tasks.filter((task) => task.status !== "pending")
.length,
total: p.values.tasks.length,
})}
</p>
</div>
<Button onClick={() => setSelected(p.id)} size="sm" variant="outline">
{t("processes.openDetail")}
</Button>
</section>
))}
</CardContent>
</Card>
)}
</>
)
)}
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={setCreate}
open={create}
title={t("processes.new")}
wide
>
{create && (
<ProcessStartForm
company={company}
csrfToken={csrfToken}
onSaved={(id) => {
setCreate(false);
setSelected(id);
}}
/>
)}
</FormDialog>
</div>
);
}

View File

@ -0,0 +1,157 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createInstance } from "i18next";
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nextProvider } from "react-i18next";
import { PersonProcessSchema } from "../../common/processes";
import { companiesEnglish, companiesSpanish } from "../i18n";
import { processKeys } from "./process-api";
import { ProcessDetail } from "./process-detail";
import { ProcessEditForm } from "./process-edit-form";
import { ProcessStartForm } from "./process-start-form";
import { ProcessTemplateForm } from "./process-template-form";
import { ProcessTemplatesPage } from "./process-templates-page";
import { ProcessesPage } from "./processes-page";
const company = {
id: "0199b200-0000-7000-8000-000000000001",
name: "Empresa",
active: true,
people: [],
contacts: [],
sites: [],
departments: [],
assignments: [],
};
async function render(language: string, node: ReactNode) {
const i18n = createInstance();
await i18n.init({
lng: language,
resources: { es: { companies: companiesSpanish }, en: { companies: companiesEnglish } },
});
const client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY } },
});
client.setQueryData(processKeys.company(company.id), { processes: [] });
client.setQueryData(processKeys.catalog(), { templates: [] });
try {
return renderToStaticMarkup(
<I18nextProvider i18n={i18n}>
<QueryClientProvider client={client}>{node}</QueryClientProvider>
</I18nextProvider>
).replaceAll("&#x27;", "'");
} finally {
client.clear();
}
}
test("procesos: listados es/en sin formularios junto a los datos", async () => {
for (const language of ["es", "en"]) {
const text = language === "es" ? companiesSpanish : companiesEnglish;
const html = await render(language, <ProcessesPage company={company} csrfToken="test" />);
assert.ok(html.includes(text.processes.new) && html.includes(text.processes.templates));
assert.ok(!html.includes("<form"));
const templates = await render(language, <ProcessTemplatesPage csrfToken="test" />);
assert.ok(templates.includes(text.processes.globalTemplates));
assert.ok(!templates.includes("<form"));
}
});
test("procesos: alta rápida y ayuda inferior antes del pie", async () => {
for (const language of ["es", "en"]) {
const text = language === "es" ? companiesSpanish : companiesEnglish;
const html = await render(
language,
<ProcessStartForm company={company} csrfToken="test" onSaved={() => undefined} />
);
assert.ok(html.includes(text.newPerson) && html.includes(text.processes.newTemplate));
assert.ok(html.indexOf(text.processes.help.title) > html.indexOf(text.mail.notes));
assert.ok(html.indexOf(text.save) > html.indexOf(text.processes.help.title));
}
});
test("plantillas: instrucciones, desactivación y ayuda global es/en sin datos de compañía", async () => {
for (const language of ["es", "en"]) {
const text = language === "es" ? companiesSpanish : companiesEnglish;
const html = await render(
language,
<ProcessTemplateForm csrfToken="test" onSaved={() => undefined} />
);
assert.ok(html.includes(text.processes.instructionsHelp));
assert.ok(html.includes(text.processes.templateHelp.scope));
assert.ok(html.indexOf(text.save) > html.indexOf(text.processes.templateHelp.title));
}
});
test("seguimiento: origen personal, revisión actual y edición sin afirmar retirada real; cerrados sin editar", async () => {
const reference = {
kind: "remote",
id: company.id,
label: "VPN de trabajo",
sourceKind: "personal_mobile",
};
const values = {
personId: company.id,
templateId: company.id,
templateName: "Salida",
kind: "offboarding",
name: "Salida Ana",
notes: null,
state: "open",
tasks: [
{
id: company.id,
title: "VPN de trabajo",
status: "done",
notes: "Conserva hasta entrega",
reference,
},
],
};
const process = PersonProcessSchema.parse({
id: company.id,
companyId: company.id,
values,
review: [reference],
history: [
{
id: company.id,
actorId: company.id,
actorName: "Técnico",
recordedAt: "2026-10-06T12:00:00.000Z",
values,
},
],
});
for (const language of ["es", "en"]) {
const text = language === "es" ? companiesSpanish : companiesEnglish;
const html = await render(
language,
<ProcessDetail csrfToken="test" person="Ana" process={process} />
);
assert.ok(html.includes(text.remote.personal_mobile));
assert.ok(html.includes(text.processes.stillRecorded));
assert.ok(
html.includes(text.processes.resolutionHelp) && html.includes(text.processes.refresh)
);
assert.ok(!html.includes("<form"));
const editor = await render(
language,
<ProcessEditForm csrfToken="test" onSaved={() => undefined} process={process} />
);
assert.ok(editor.includes(text.processes.taskNotes));
assert.ok(editor.includes(text.processes.resolutionHelp));
assert.ok(editor.indexOf(text.save) > editor.indexOf(text.processes.help.title));
const closed = await render(
language,
<ProcessDetail
csrfToken="test"
person="Ana"
process={{ ...process, values: { ...process.values, state: "completed" } }}
/>
);
assert.ok(!(closed.includes(text.processes.update) || closed.includes(text.processes.refresh)));
}
});

View File

@ -1,5 +1,5 @@
export const profilesSpanish = {
title: "Perfiles de trabajo",
title: "Perfiles de usuario",
new: "Nuevo perfil",
edit: "Editar perfil",
name: "Nombre del perfil",
@ -12,14 +12,14 @@ export const profilesSpanish = {
choose: "Selecciona un elemento",
notes: "Notas (opcional)",
search: "Buscar por perfil, persona o dispositivo",
empty: "Esta compañía todavía no tiene perfiles de trabajo.",
empty: "Esta compañía todavía no tiene perfiles de usuario.",
history: "Ficha e historial",
showArchived: "Mostrar también los perfiles archivados",
review:
"Revisar: la persona, el dispositivo o la cuenta ya no están disponibles para este perfil.",
fixed:
"La persona y el dispositivo identifican este perfil. Para otro dispositivo o persona, crea un perfil nuevo.",
help: "¿Para qué sirve un perfil de trabajo?",
help: "¿Para qué sirve un perfil de usuario?",
description:
"Identifica el entorno de una persona en un dispositivo concreto. Una persona puede tener varios perfiles y un equipo puede tener perfiles de varias personas.",
guidance:
@ -48,7 +48,7 @@ export const profilesSpanish = {
},
};
export const profilesEnglish = {
title: "Work profiles",
title: "User profiles",
new: "New profile",
edit: "Edit profile",
name: "Profile name",
@ -61,13 +61,13 @@ export const profilesEnglish = {
choose: "Choose an item",
notes: "Notes (optional)",
search: "Search by profile, person or device",
empty: "This company has no work profiles yet.",
empty: "This company has no user profiles yet.",
history: "Details and history",
showArchived: "Include archived profiles",
review: "Review: the person, device or account is no longer available for this profile.",
fixed:
"Person and device identify this profile. Create another profile for a different person or device.",
help: "What is a work profile for?",
help: "What is a user profile for?",
description:
"Identifies a person's environment on a specific device. A person can have several profiles and a device can host profiles for several people.",
guidance:

View File

@ -70,6 +70,6 @@ test("perfiles: cuenta opcional, altas rápidas y ayuda anterior al pie", async
}
assert.ok(html.includes("Cuenta de inicio de sesión (opcional)"));
assert.ok(html.includes("Un perfil vigente no significa"));
assert.ok(html.indexOf("¿Para qué sirve un perfil de trabajo?") < html.indexOf("Guardar"));
assert.ok(html.indexOf("¿Para qué sirve un perfil de usuario?") < html.indexOf("Guardar"));
assert.equal(html.includes('type="password"'), false);
});

View File

@ -5,6 +5,7 @@ export interface IResourceInventoryService {
list(company: UniqueID): Promise<
readonly {
id: UniqueID;
name: string;
active: boolean;
usage: "application" | "certificate" | null;
expiresOn: string | null;
@ -40,6 +41,7 @@ export class ResourceInventoryService implements IResourceInventoryService {
: null;
return {
id: resource.id,
name: resource.values.toPrimitive().name,
active: resource.values.toPrimitive().active,
usage,
expiresOn: typeof expiresOn === "string" ? expiresOn : null,

View File

@ -14,7 +14,7 @@ export const resourcesSpanish = {
newTemplate: "Nueva plantilla",
templateUsage: "Uso de la plantilla",
templateUsageHelp:
"Permite utilizar sus recursos en perfiles de trabajo. El uso queda fijo tras el alta.",
"Permite utilizar sus recursos en perfiles de usuario. El uso queda fijo tras el alta.",
usage: { general: "General", application: "Aplicación", certificate: "Certificado digital" },
editTemplate: "Editar plantilla",
name: "Nombre",
@ -119,7 +119,7 @@ export const resourcesEnglish = {
editField: "Edit field",
newTemplate: "New template",
templateUsage: "Template use",
templateUsageHelp: "Enables resources in work profiles. The use is fixed after creation.",
templateUsageHelp: "Enables resources in user profiles. The use is fixed after creation.",
usage: { general: "General", application: "Application", certificate: "Digital certificate" },
editTemplate: "Edit template",
name: "Name",