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:
parent
d5eafe6f85
commit
dffb4b2794
@ -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(
|
||||
|
||||
283
apps/server/src/processes.integration.ts
Normal file
283
apps/server/src/processes.integration.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
92
docs/procesos-de-personas.md
Normal file
92
docs/procesos-de-personas.md
Normal 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.
|
||||
@ -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.
|
||||
|
||||
@ -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",
|
||||
}));
|
||||
},
|
||||
|
||||
@ -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:*",
|
||||
|
||||
@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@ -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 [];
|
||||
});
|
||||
}
|
||||
@ -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(),
|
||||
];
|
||||
}
|
||||
34
modules/companies/src/api/application/process.repository.ts
Normal file
34
modules/companies/src/api/application/process.repository.ts
Normal 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 }[]
|
||||
>;
|
||||
}
|
||||
175
modules/companies/src/api/application/process.use-cases.ts
Normal file
175
modules/companies/src/api/application/process.use-cases.ts
Normal 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()
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
23
modules/companies/src/api/domain/process-revision.entity.ts
Normal file
23
modules/companies/src/api/domain/process-revision.entity.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
109
modules/companies/src/api/domain/process-task.entity.ts
Normal file
109
modules/companies/src/api/domain/process-task.entity.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
91
modules/companies/src/api/domain/process-values.ts
Normal file
91
modules/companies/src/api/domain/process-values.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
146
modules/companies/src/api/domain/process.aggregate.ts
Normal file
146
modules/companies/src/api/domain/process.aggregate.ts
Normal 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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
285
modules/companies/src/api/domain/process.test.ts
Normal file
285
modules/companies/src/api/domain/process.test.ts
Normal 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"));
|
||||
});
|
||||
@ -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()),
|
||||
};
|
||||
}
|
||||
}
|
||||
101
modules/companies/src/api/domain/value-objects/process-values.ts
Normal file
101
modules/companies/src/api/domain/value-objects/process-values.ts
Normal 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()),
|
||||
};
|
||||
}
|
||||
}
|
||||
183
modules/companies/src/api/infrastructure/http/process.routes.ts
Normal file
183
modules/companies/src/api/infrastructure/http/process.routes.ts
Normal 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);
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
@ -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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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";
|
||||
|
||||
52
modules/companies/src/common/processes.ts
Normal file
52
modules/companies/src/common/processes.ts
Normal 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>;
|
||||
@ -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":
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:",
|
||||
|
||||
64
modules/companies/src/web/processes/process-api.ts
Normal file
64
modules/companies/src/web/processes/process-api.ts
Normal 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)
|
||||
);
|
||||
},
|
||||
};
|
||||
154
modules/companies/src/web/processes/process-detail.tsx
Normal file
154
modules/companies/src/web/processes/process-detail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
113
modules/companies/src/web/processes/process-edit-form.tsx
Normal file
113
modules/companies/src/web/processes/process-edit-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
modules/companies/src/web/processes/process-feedback.tsx
Normal file
25
modules/companies/src/web/processes/process-feedback.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
modules/companies/src/web/processes/process-help.tsx
Normal file
16
modules/companies/src/web/processes/process-help.tsx
Normal 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`)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
193
modules/companies/src/web/processes/process-i18n.ts
Normal file
193
modules/companies/src/web/processes/process-i18n.ts
Normal 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.",
|
||||
},
|
||||
};
|
||||
10
modules/companies/src/web/processes/process-labels.ts
Normal file
10
modules/companies/src/web/processes/process-labels.ts
Normal 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;
|
||||
}
|
||||
174
modules/companies/src/web/processes/process-start-form.tsx
Normal file
174
modules/companies/src/web/processes/process-start-form.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
64
modules/companies/src/web/processes/process-task-field.tsx
Normal file
64
modules/companies/src/web/processes/process-task-field.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
164
modules/companies/src/web/processes/process-template-form.tsx
Normal file
164
modules/companies/src/web/processes/process-template-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
130
modules/companies/src/web/processes/process-templates-page.tsx
Normal file
130
modules/companies/src/web/processes/process-templates-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
156
modules/companies/src/web/processes/processes-page.tsx
Normal file
156
modules/companies/src/web/processes/processes-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
157
modules/companies/src/web/processes/processes.test.tsx
Normal file
157
modules/companies/src/web/processes/processes.test.tsx
Normal 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("'", "'");
|
||||
} 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)));
|
||||
}
|
||||
});
|
||||
@ -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:
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user