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 { verifyInventory } from "./inventory.integration";
|
||||||
import { verifyMail } from "./mail.integration";
|
import { verifyMail } from "./mail.integration";
|
||||||
import { verifyPrinters } from "./printers.integration";
|
import { verifyPrinters } from "./printers.integration";
|
||||||
|
import { verifyProcesses } from "./processes.integration";
|
||||||
import { verifyRelationships } from "./relationships.integration";
|
import { verifyRelationships } from "./relationships.integration";
|
||||||
import { verifyRemoteConnections } from "./remote.integration";
|
import { verifyRemoteConnections } from "./remote.integration";
|
||||||
import { verifyResourceMigration } from "./resource-migration.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 application_configurations");
|
||||||
await database.query("DELETE FROM certificate_configuration_revisions");
|
await database.query("DELETE FROM certificate_configuration_revisions");
|
||||||
await database.query("DELETE FROM certificate_configurations");
|
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_connection_revisions");
|
||||||
await database.query("DELETE FROM remote_connections");
|
await database.query("DELETE FROM remote_connections");
|
||||||
await database.query("DELETE FROM telework_authorization_revisions");
|
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,
|
secondCompany.id,
|
||||||
database
|
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í.
|
// El entorno normal bloquea las fichas antiguas; el modo de fixtures anterior no se usa aquí.
|
||||||
const migratedRuntime = await createIdentityRuntime(database, environment);
|
const migratedRuntime = await createIdentityRuntime(database, environment);
|
||||||
const migratedServer = createApp(() => undefined, migratedRuntime).listen(0, "127.0.0.1");
|
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,
|
401,
|
||||||
"UNAUTHENTICATED"
|
"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("/users"), 401, "UNAUTHENTICATED");
|
||||||
await expectError(await operator.request("/companies"), 401, "UNAUTHENTICATED");
|
await expectError(await operator.request("/companies"), 401, "UNAUTHENTICATED");
|
||||||
await assert.rejects(
|
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.
|
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.
|
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
|
## 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.
|
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).
|
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.
|
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
|
## 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).
|
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
|
## 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.
|
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
|
# 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
|
## Equipo y puesto
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Uso
|
## 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.
|
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
|
## 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.
|
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.
|
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 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).
|
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
|
## 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).
|
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
|
## 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.
|
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. 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.
|
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.
|
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) => ({
|
return result.data.assets.map((asset) => ({
|
||||||
id: asset.id,
|
id: asset.id,
|
||||||
|
name: asset.values.toPrimitive().name,
|
||||||
|
personId: asset.values.toPrimitive().personId,
|
||||||
available: asset.values.toPrimitive().status !== "retired",
|
available: asset.values.toPrimitive().status !== "retired",
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc -p tsconfig.json",
|
"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": {
|
"dependencies": {
|
||||||
"@repo/rdx-ddd": "workspace:*",
|
"@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 { MailConfigurationUseCases } from "./application/mail-configuration.use-cases";
|
||||||
import { MailboxUseCases } from "./application/mailbox.use-cases";
|
import { MailboxUseCases } from "./application/mailbox.use-cases";
|
||||||
import { PrinterConfigurationUseCases } from "./application/printer-configuration.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 { RemoteConnectionUseCases } from "./application/remote-connection.use-cases";
|
||||||
import { TeleworkAuthorizationUseCases } from "./application/telework-authorization.use-cases";
|
import { TeleworkAuthorizationUseCases } from "./application/telework-authorization.use-cases";
|
||||||
import type { IWorkProfileInventoryService } from "./application/work-profile.repository";
|
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 { registerMailRoutes } from "./infrastructure/http/mail-configuration.routes";
|
||||||
import { registerMailboxRoutes } from "./infrastructure/http/mailbox.routes";
|
import { registerMailboxRoutes } from "./infrastructure/http/mailbox.routes";
|
||||||
import { registerPrinterConfigurationRoutes } from "./infrastructure/http/printer-configuration.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 { registerRemoteConnectionRoutes } from "./infrastructure/http/remote-connection.routes";
|
||||||
import { registerTeleworkAuthorizationRoutes } from "./infrastructure/http/telework-authorization.routes";
|
import { registerTeleworkAuthorizationRoutes } from "./infrastructure/http/telework-authorization.routes";
|
||||||
import { registerWorkProfileRoutes } from "./infrastructure/http/work-profile.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 { migratePeople } from "./infrastructure/persistence/migrate-people";
|
||||||
import { initializePeopleModels } from "./infrastructure/persistence/people.model";
|
import { initializePeopleModels } from "./infrastructure/persistence/people.model";
|
||||||
import { migratePrinterConfigurations } from "./infrastructure/persistence/printer-configuration-migration";
|
import { migratePrinterConfigurations } from "./infrastructure/persistence/printer-configuration-migration";
|
||||||
|
import { migrateProcesses } from "./infrastructure/persistence/process-migration";
|
||||||
import { migrateRemoteConnections } from "./infrastructure/persistence/remote-connection-migration";
|
import { migrateRemoteConnections } from "./infrastructure/persistence/remote-connection-migration";
|
||||||
import { SequelizeApplicationConfigurationRepository } from "./infrastructure/persistence/sequelize-application-configuration.repository";
|
import { SequelizeApplicationConfigurationRepository } from "./infrastructure/persistence/sequelize-application-configuration.repository";
|
||||||
import { SequelizeCertificateConfigurationRepository } from "./infrastructure/persistence/sequelize-certificate-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 { SequelizeMailConfigurationRepository } from "./infrastructure/persistence/sequelize-mail-configuration.repository";
|
||||||
import { SequelizeMailboxRepository } from "./infrastructure/persistence/sequelize-mailbox.repository";
|
import { SequelizeMailboxRepository } from "./infrastructure/persistence/sequelize-mailbox.repository";
|
||||||
import { SequelizePrinterConfigurationRepository } from "./infrastructure/persistence/sequelize-printer-configuration.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 { SequelizeRemoteConnectionRepository } from "./infrastructure/persistence/sequelize-remote-connection.repository";
|
||||||
import { SequelizeTeleworkAuthorizationRepository } from "./infrastructure/persistence/sequelize-telework-authorization.repository";
|
import { SequelizeTeleworkAuthorizationRepository } from "./infrastructure/persistence/sequelize-telework-authorization.repository";
|
||||||
import { SequelizeWorkProfileRepository } from "./infrastructure/persistence/sequelize-work-profile.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());
|
const useCases = new CompanyUseCases(repository, new CompanyInputMapper());
|
||||||
return {
|
return {
|
||||||
initializeProfiles: async (
|
initializeProfiles: async (
|
||||||
inventory: IWorkProfileInventoryService,
|
inventory: IWorkProfileInventoryService & IProcessAssetInventoryService,
|
||||||
resources: IResourceInventoryService
|
resources: IResourceInventoryService
|
||||||
) => {
|
) => {
|
||||||
|
await migrateProcesses(database);
|
||||||
|
const processes = new ProcessUseCases(
|
||||||
|
new SequelizeProcessRepository(database, inventory, resources)
|
||||||
|
);
|
||||||
await migrateRemoteConnections(database);
|
await migrateRemoteConnections(database);
|
||||||
const remote = new RemoteConnectionUseCases(
|
const remote = new RemoteConnectionUseCases(
|
||||||
new SequelizeRemoteConnectionRepository(database, inventory, resources)
|
new SequelizeRemoteConnectionRepository(database, inventory, resources)
|
||||||
@ -107,6 +116,7 @@ export async function createCompanyModule(
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
register: (app: Parameters<typeof registerWorkProfileRoutes>[0]) => {
|
register: (app: Parameters<typeof registerWorkProfileRoutes>[0]) => {
|
||||||
|
registerProcessRoutes(app, processes, authenticate);
|
||||||
registerRemoteConnectionRoutes(app, remote, authenticate);
|
registerRemoteConnectionRoutes(app, remote, authenticate);
|
||||||
registerWorkProfileRoutes(app, profiles, authenticate);
|
registerWorkProfileRoutes(app, profiles, authenticate);
|
||||||
registerFolderMappingRoutes(app, folders, authenticate);
|
registerFolderMappingRoutes(app, folders, authenticate);
|
||||||
|
|||||||
@ -11,6 +11,7 @@ export * from "./folder-mappings";
|
|||||||
export * from "./mail-configurations";
|
export * from "./mail-configurations";
|
||||||
export * from "./mailboxes";
|
export * from "./mailboxes";
|
||||||
export * from "./printer-configurations";
|
export * from "./printer-configurations";
|
||||||
|
export * from "./processes";
|
||||||
export * from "./remote-connections";
|
export * from "./remote-connections";
|
||||||
export * from "./telework-authorizations";
|
export * from "./telework-authorizations";
|
||||||
export * from "./work-profiles";
|
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 { MailConfigurationsPage } from "../mail/mail-configurations-page";
|
||||||
import { MailboxRelations } from "../mail/mailbox-relations";
|
import { MailboxRelations } from "../mail/mailbox-relations";
|
||||||
import { PrinterConfigurationsPage } from "../printers/printer-configurations-page";
|
import { PrinterConfigurationsPage } from "../printers/printer-configurations-page";
|
||||||
|
import { ProcessesPage } from "../processes/processes-page";
|
||||||
import { WorkProfilesPage } from "../profiles/work-profiles-page";
|
import { WorkProfilesPage } from "../profiles/work-profiles-page";
|
||||||
import { RemoteConnectionsPage } from "../remote/remote-connections-page";
|
import { RemoteConnectionsPage } from "../remote/remote-connections-page";
|
||||||
import { TeleworkAuthorizationsPage } from "../telework/telework-authorizations-page";
|
import { TeleworkAuthorizationsPage } from "../telework/telework-authorizations-page";
|
||||||
@ -112,6 +113,7 @@ function SiteSection({ company, csrfToken }: { company: CompanyDTO; csrfToken: s
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
type CompanyTab =
|
type CompanyTab =
|
||||||
|
| "processes"
|
||||||
| "remote"
|
| "remote"
|
||||||
| "telework"
|
| "telework"
|
||||||
| "people"
|
| "people"
|
||||||
@ -222,6 +224,7 @@ export function CompanyDetail({
|
|||||||
["profiles", t("profiles.title"), UsersIcon],
|
["profiles", t("profiles.title"), UsersIcon],
|
||||||
["telework", t("telework.title"), KeyRoundIcon],
|
["telework", t("telework.title"), KeyRoundIcon],
|
||||||
["remote", t("remote.title"), KeyRoundIcon],
|
["remote", t("remote.title"), KeyRoundIcon],
|
||||||
|
["processes", t("processes.title"), LayoutTemplateIcon],
|
||||||
["people", `${t("people")} (${company.people.length})`, UsersIcon],
|
["people", `${t("people")} (${company.people.length})`, UsersIcon],
|
||||||
[
|
[
|
||||||
"departments",
|
"departments",
|
||||||
@ -271,6 +274,8 @@ function CompanySection({
|
|||||||
tab: CompanyTab;
|
tab: CompanyTab;
|
||||||
}) {
|
}) {
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
|
case "processes":
|
||||||
|
return <ProcessesPage company={company} csrfToken={csrfToken} />;
|
||||||
case "remote":
|
case "remote":
|
||||||
return <RemoteConnectionsPage company={company} csrfToken={csrfToken} />;
|
return <RemoteConnectionsPage company={company} csrfToken={csrfToken} />;
|
||||||
case "telework":
|
case "telework":
|
||||||
|
|||||||
@ -4,12 +4,14 @@ import { foldersEnglish, foldersSpanish } from "./folders/folder-i18n";
|
|||||||
import { mailEnglish, mailSpanish } from "./mail/mail-i18n";
|
import { mailEnglish, mailSpanish } from "./mail/mail-i18n";
|
||||||
import { mailboxEnglish, mailboxSpanish } from "./mail/mailbox-i18n";
|
import { mailboxEnglish, mailboxSpanish } from "./mail/mailbox-i18n";
|
||||||
import { printersEnglish, printersSpanish } from "./printers/printer-i18n";
|
import { printersEnglish, printersSpanish } from "./printers/printer-i18n";
|
||||||
|
import { processesEnglish, processesSpanish } from "./processes/process-i18n";
|
||||||
import { environmentEnglish, environmentSpanish } from "./profiles/environment-i18n";
|
import { environmentEnglish, environmentSpanish } from "./profiles/environment-i18n";
|
||||||
import { profilesEnglish, profilesSpanish } from "./profiles/profile-i18n";
|
import { profilesEnglish, profilesSpanish } from "./profiles/profile-i18n";
|
||||||
import { remoteEnglish, remoteSpanish } from "./remote/remote-i18n";
|
import { remoteEnglish, remoteSpanish } from "./remote/remote-i18n";
|
||||||
import { teleworkEnglish, teleworkSpanish } from "./telework/telework-i18n";
|
import { teleworkEnglish, teleworkSpanish } from "./telework/telework-i18n";
|
||||||
|
|
||||||
export const companiesSpanish = {
|
export const companiesSpanish = {
|
||||||
|
processes: processesSpanish,
|
||||||
remote: remoteSpanish,
|
remote: remoteSpanish,
|
||||||
telework: teleworkSpanish,
|
telework: teleworkSpanish,
|
||||||
environment: environmentSpanish,
|
environment: environmentSpanish,
|
||||||
@ -134,6 +136,7 @@ export const companiesSpanish = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
export const companiesEnglish = {
|
export const companiesEnglish = {
|
||||||
|
processes: processesEnglish,
|
||||||
remote: remoteEnglish,
|
remote: remoteEnglish,
|
||||||
telework: teleworkEnglish,
|
telework: teleworkEnglish,
|
||||||
environment: environmentEnglish,
|
environment: environmentEnglish,
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
export const mailSpanish = {
|
export const mailSpanish = {
|
||||||
title: "Correos en dispositivos",
|
title: "Correos en dispositivos",
|
||||||
new: "Configurar correo",
|
new: "Configurar correo",
|
||||||
profile: "Perfil de trabajo",
|
profile: "Perfil de usuario",
|
||||||
legacy: "Registro anterior pendiente de vincular a buzón y perfil.",
|
legacy: "Registro anterior pendiente de vincular a buzón y perfil.",
|
||||||
legacyLink:
|
legacyLink:
|
||||||
"Selecciona el perfil de esta persona en este dispositivo y un buzón con la misma dirección. Se conservará el historial anterior:",
|
"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 = {
|
export const mailEnglish = {
|
||||||
title: "Email on devices",
|
title: "Email on devices",
|
||||||
new: "Configure email",
|
new: "Configure email",
|
||||||
profile: "Work profile",
|
profile: "User profile",
|
||||||
legacy: "Previous record awaiting mailbox and profile linking.",
|
legacy: "Previous record awaiting mailbox and profile linking.",
|
||||||
legacyLink:
|
legacyLink:
|
||||||
"Select this person’s profile on this device and a mailbox with the same address. Previous history will be retained:",
|
"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 = {
|
export const profilesSpanish = {
|
||||||
title: "Perfiles de trabajo",
|
title: "Perfiles de usuario",
|
||||||
new: "Nuevo perfil",
|
new: "Nuevo perfil",
|
||||||
edit: "Editar perfil",
|
edit: "Editar perfil",
|
||||||
name: "Nombre del perfil",
|
name: "Nombre del perfil",
|
||||||
@ -12,14 +12,14 @@ export const profilesSpanish = {
|
|||||||
choose: "Selecciona un elemento",
|
choose: "Selecciona un elemento",
|
||||||
notes: "Notas (opcional)",
|
notes: "Notas (opcional)",
|
||||||
search: "Buscar por perfil, persona o dispositivo",
|
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",
|
history: "Ficha e historial",
|
||||||
showArchived: "Mostrar también los perfiles archivados",
|
showArchived: "Mostrar también los perfiles archivados",
|
||||||
review:
|
review:
|
||||||
"Revisar: la persona, el dispositivo o la cuenta ya no están disponibles para este perfil.",
|
"Revisar: la persona, el dispositivo o la cuenta ya no están disponibles para este perfil.",
|
||||||
fixed:
|
fixed:
|
||||||
"La persona y el dispositivo identifican este perfil. Para otro dispositivo o persona, crea un perfil nuevo.",
|
"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:
|
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.",
|
"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:
|
guidance:
|
||||||
@ -48,7 +48,7 @@ export const profilesSpanish = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
export const profilesEnglish = {
|
export const profilesEnglish = {
|
||||||
title: "Work profiles",
|
title: "User profiles",
|
||||||
new: "New profile",
|
new: "New profile",
|
||||||
edit: "Edit profile",
|
edit: "Edit profile",
|
||||||
name: "Profile name",
|
name: "Profile name",
|
||||||
@ -61,13 +61,13 @@ export const profilesEnglish = {
|
|||||||
choose: "Choose an item",
|
choose: "Choose an item",
|
||||||
notes: "Notes (optional)",
|
notes: "Notes (optional)",
|
||||||
search: "Search by profile, person or device",
|
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",
|
history: "Details and history",
|
||||||
showArchived: "Include archived profiles",
|
showArchived: "Include archived profiles",
|
||||||
review: "Review: the person, device or account is no longer available for this profile.",
|
review: "Review: the person, device or account is no longer available for this profile.",
|
||||||
fixed:
|
fixed:
|
||||||
"Person and device identify this profile. Create another profile for a different person or device.",
|
"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:
|
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.",
|
"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:
|
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("Cuenta de inicio de sesión (opcional)"));
|
||||||
assert.ok(html.includes("Un perfil vigente no significa"));
|
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);
|
assert.equal(html.includes('type="password"'), false);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,6 +5,7 @@ export interface IResourceInventoryService {
|
|||||||
list(company: UniqueID): Promise<
|
list(company: UniqueID): Promise<
|
||||||
readonly {
|
readonly {
|
||||||
id: UniqueID;
|
id: UniqueID;
|
||||||
|
name: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
usage: "application" | "certificate" | null;
|
usage: "application" | "certificate" | null;
|
||||||
expiresOn: string | null;
|
expiresOn: string | null;
|
||||||
@ -40,6 +41,7 @@ export class ResourceInventoryService implements IResourceInventoryService {
|
|||||||
: null;
|
: null;
|
||||||
return {
|
return {
|
||||||
id: resource.id,
|
id: resource.id,
|
||||||
|
name: resource.values.toPrimitive().name,
|
||||||
active: resource.values.toPrimitive().active,
|
active: resource.values.toPrimitive().active,
|
||||||
usage,
|
usage,
|
||||||
expiresOn: typeof expiresOn === "string" ? expiresOn : null,
|
expiresOn: typeof expiresOn === "string" ? expiresOn : null,
|
||||||
|
|||||||
@ -14,7 +14,7 @@ export const resourcesSpanish = {
|
|||||||
newTemplate: "Nueva plantilla",
|
newTemplate: "Nueva plantilla",
|
||||||
templateUsage: "Uso de la plantilla",
|
templateUsage: "Uso de la plantilla",
|
||||||
templateUsageHelp:
|
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" },
|
usage: { general: "General", application: "Aplicación", certificate: "Certificado digital" },
|
||||||
editTemplate: "Editar plantilla",
|
editTemplate: "Editar plantilla",
|
||||||
name: "Nombre",
|
name: "Nombre",
|
||||||
@ -119,7 +119,7 @@ export const resourcesEnglish = {
|
|||||||
editField: "Edit field",
|
editField: "Edit field",
|
||||||
newTemplate: "New template",
|
newTemplate: "New template",
|
||||||
templateUsage: "Template use",
|
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" },
|
usage: { general: "General", application: "Application", certificate: "Digital certificate" },
|
||||||
editTemplate: "Edit template",
|
editTemplate: "Edit template",
|
||||||
name: "Name",
|
name: "Name",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user