From 2382e0ea203d4607c61f73149111e95d7f79a151 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 6 Oct 2026 18:10:35 +0200 Subject: [PATCH] feat: add telework authorization management functionality - Implemented RESTful API routes for telework authorizations, including GET and POST methods for retrieving and saving authorizations. - Created Sequelize repository for managing telework authorization data with methods for loading and saving authorizations. - Developed migration scripts to set up necessary database tables for telework authorizations and revisions. - Added persistence mapping for telework authorization entities to facilitate data transformation between domain and database layers. - Introduced common schemas for validating telework authorization requests and responses using Zod. - Built frontend components for managing telework authorizations, including forms for creating and editing authorizations, and summaries for displaying authorization details. - Integrated internationalization support for English and Spanish languages in the telework module. - Added unit tests to ensure functionality and correctness of telework authorization components and API interactions. --- apps/server/src/identity.test.ts | 19 ++ apps/server/src/telework.integration.ts | 153 +++++++++++++++ .../modelo-ddd-e-identificadores.md | 4 + docs/cuentas-grupos-y-recursos.md | 2 +- docs/entorno-actual.md | 4 + docs/inventario-de-activos.md | 2 +- docs/personas-y-departamentos.md | 2 +- docs/plan-de-desarrollo.md | 6 +- docs/teletrabajo.md | 55 ++++++ modules/companies/package.json | 2 +- .../telework-authorization-input.mapper.ts | 46 +++++ .../telework-authorization-policy.ts | 25 +++ ...telework-authorization-response.builder.ts | 28 +++ .../telework-authorization.repository.ts | 28 +++ .../telework-authorization.use-cases.ts | 47 +++++ .../telework-authorization-revision.entity.ts | 25 +++ .../domain/telework-authorization-values.ts | 20 ++ .../telework-authorization.aggregate.ts | 78 ++++++++ .../api/domain/telework-authorization.test.ts | 183 +++++++++++++++++ .../telework-authorization-values.ts | 68 +++++++ .../http/telework-authorization.routes.ts | 84 ++++++++ ...elize-telework-authorization.repository.ts | 109 +++++++++++ .../telework-authorization-migration.ts | 42 ++++ ...lework-authorization-persistence.mapper.ts | 83 ++++++++ modules/companies/src/api/module.ts | 9 + modules/companies/src/common/index.ts | 1 + .../src/common/telework-authorizations.ts | 38 ++++ .../src/web/components/company-detail.tsx | 5 + modules/companies/src/web/i18n.ts | 3 + .../web/profiles/work-environment-page.tsx | 43 +++- .../web/profiles/work-environment.test.tsx | 49 ++++- .../src/web/profiles/work-profiles-page.tsx | 13 +- .../src/web/telework/telework-api.ts | 43 ++++ .../telework/telework-authorization-form.tsx | 185 ++++++++++++++++++ .../telework-authorization-summary.tsx | 48 +++++ .../telework/telework-authorizations-page.tsx | 166 ++++++++++++++++ .../src/web/telework/telework-feedback.tsx | 18 ++ .../src/web/telework/telework-i18n.ts | 95 +++++++++ .../src/web/telework/telework.test.tsx | 126 ++++++++++++ 39 files changed, 1947 insertions(+), 10 deletions(-) create mode 100644 apps/server/src/telework.integration.ts create mode 100644 docs/teletrabajo.md create mode 100644 modules/companies/src/api/application/telework-authorization-input.mapper.ts create mode 100644 modules/companies/src/api/application/telework-authorization-policy.ts create mode 100644 modules/companies/src/api/application/telework-authorization-response.builder.ts create mode 100644 modules/companies/src/api/application/telework-authorization.repository.ts create mode 100644 modules/companies/src/api/application/telework-authorization.use-cases.ts create mode 100644 modules/companies/src/api/domain/telework-authorization-revision.entity.ts create mode 100644 modules/companies/src/api/domain/telework-authorization-values.ts create mode 100644 modules/companies/src/api/domain/telework-authorization.aggregate.ts create mode 100644 modules/companies/src/api/domain/telework-authorization.test.ts create mode 100644 modules/companies/src/api/domain/value-objects/telework-authorization-values.ts create mode 100644 modules/companies/src/api/infrastructure/http/telework-authorization.routes.ts create mode 100644 modules/companies/src/api/infrastructure/persistence/sequelize-telework-authorization.repository.ts create mode 100644 modules/companies/src/api/infrastructure/persistence/telework-authorization-migration.ts create mode 100644 modules/companies/src/api/infrastructure/persistence/telework-authorization-persistence.mapper.ts create mode 100644 modules/companies/src/common/telework-authorizations.ts create mode 100644 modules/companies/src/web/telework/telework-api.ts create mode 100644 modules/companies/src/web/telework/telework-authorization-form.tsx create mode 100644 modules/companies/src/web/telework/telework-authorization-summary.tsx create mode 100644 modules/companies/src/web/telework/telework-authorizations-page.tsx create mode 100644 modules/companies/src/web/telework/telework-feedback.tsx create mode 100644 modules/companies/src/web/telework/telework-i18n.ts create mode 100644 modules/companies/src/web/telework/telework.test.tsx diff --git a/apps/server/src/identity.test.ts b/apps/server/src/identity.test.ts index f9d56c8..4722891 100644 --- a/apps/server/src/identity.test.ts +++ b/apps/server/src/identity.test.ts @@ -32,6 +32,7 @@ import { verifyPrinters } from "./printers.integration"; import { verifyRelationships } from "./relationships.integration"; import { verifyResourceMigration } from "./resource-migration.integration"; import { verifyResources } from "./resources.integration"; +import { verifyTelework } from "./telework.integration"; import { verifyWorkProfiles } from "./work-profiles.integration"; import { verifyWorkstations } from "./workstations.integration"; @@ -62,6 +63,8 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de await database.query("DELETE FROM application_configurations"); await database.query("DELETE FROM certificate_configuration_revisions"); await database.query("DELETE FROM certificate_configurations"); + await database.query("DELETE FROM telework_authorization_revisions"); + await database.query("DELETE FROM telework_authorizations"); await database.query("DELETE FROM work_profile_revisions"); await database.query("DELETE FROM work_profiles"); await database.query("DELETE FROM workstation_assets"); @@ -775,6 +778,12 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de secondCompany.id, database ); + await verifyTelework( + (...args) => operator.request(...args), + customer.id, + secondCompany.id, + database + ); await verifyPrinters( (...args) => operator.request(...args), customer.id, @@ -844,6 +853,16 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de 401, "UNAUTHENTICATED" ); + await expectError( + await operator.request(`/telework-authorizations/${customer.id}`), + 401, + "UNAUTHENTICATED" + ); + await expectError( + await operator.request(`/telework-authorizations/${customer.id}`, {}), + 401, + "UNAUTHENTICATED" + ); await expectError(await operator.request("/users"), 401, "UNAUTHENTICATED"); await expectError(await operator.request("/companies"), 401, "UNAUTHENTICATED"); await assert.rejects( diff --git a/apps/server/src/telework.integration.ts b/apps/server/src/telework.integration.ts new file mode 100644 index 0000000..4d3299d --- /dev/null +++ b/apps/server/src/telework.integration.ts @@ -0,0 +1,153 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; + +import { + CompanyResponseSchema, + type TeleworkAuthorizationInput, + TeleworkAuthorizationResponseSchema, + TeleworkAuthorizationsResponseSchema, +} from "@metis/companies/common"; +import type { Sequelize } from "sequelize"; +import { z } from "zod/v4"; + +type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise; +export async function verifyTelework( + request: Request, + companyId: string, + otherId: string, + database: Sequelize +) { + const personValues = { + name: "Persona teletrabajo", + position: null, + email: null, + phone: null, + active: true, + isContact: false, + }; + const createdPerson = await request(`/companies/${companyId}/people`, personValues); + assert.equal(createdPerson.status, 201); + const person = CompanyResponseSchema.parse(await createdPerson.json()).company.people.at(-1); + assert.ok(person); + const path = `/telework-authorizations/${companyId}`; + const input: TeleworkAuthorizationInput = { + personId: person.id, + active: true, + startsOn: null, + expiresOn: null, + notes: null, + }; + const error = async (values: unknown, code: string, id?: string, status = 409) => { + const response = await request(path + (id ? `/${id}/update` : ""), { values }); + assert.equal(response.status, status); + assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code); + }; + const save = async (values: TeleworkAuthorizationInput, id: string) => { + const response = await request(`${path}/${id}/update`, { values }); + assert.equal(response.status, 200); + return TeleworkAuthorizationResponseSchema.parse(await response.json()).authorization; + }; + const list = async (scope = companyId) => + TeleworkAuthorizationsResponseSchema.parse( + await (await request(`/telework-authorizations/${scope}`)).json() + ).authorizations; + await error({ ...input, actorId: person.id }, "INVALID_REQUEST", undefined, 400); + await error( + { ...input, startsOn: "2026-12-01", expiresOn: "2026-11-30" }, + "INVALID_REQUEST", + undefined, + 400 + ); + await error({ ...input, personId: randomUUID() }, "TELEWORK_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, "a".repeat(64))).status, 403); + const concurrent = await Promise.all([ + request(path, { values: input }), + request(path, { values: input }), + ]); + assert.deepEqual(concurrent.map((response) => response.status).sort(), [201, 409]); + const created = concurrent.find((response) => response.status === 201); + assert.ok(created); + const authorization = TeleworkAuthorizationResponseSchema.parse( + await created.json() + ).authorization; + assert.equal(authorization.id[14], "7"); + assert.equal(authorization.history[0]?.id[14], "7"); + assert.equal(authorization.effective, true); + assert.equal(authorization.status, "authorized"); + assert.equal(authorization.needsReview, false); + assert.deepEqual(await save(input, authorization.id), authorization); + await error({ ...input, personId: randomUUID() }, "TELEWORK_PERSON_IMMUTABLE", authorization.id); + const otherCreate = await request(`/telework-authorizations/${otherId}`, { values: input }); + assert.equal(otherCreate.status, 404); + assert.equal((await list(otherId)).length, 0); + const foreignUpdate = await request( + `/telework-authorizations/${otherId}/${authorization.id}/update`, + { values: input } + ); + assert.equal(foreignUpdate.status, 404); + const today = new Date().toISOString().slice(0, 10); + const oneDay = await save({ ...input, startsOn: today, expiresOn: today }, authorization.id); + assert.equal(oneDay.status, "authorized"); + assert.equal(oneDay.effective, true); + const scheduled = await save( + { ...input, startsOn: "9999-12-31", expiresOn: "9999-12-31" }, + authorization.id + ); + assert.equal(scheduled.status, "scheduled"); + assert.equal(scheduled.effective, false); + const expired = await save( + { ...input, startsOn: "2000-01-01", expiresOn: "2000-01-01" }, + authorization.id + ); + assert.equal(expired.status, "expired"); + assert.equal(expired.effective, false); + const renewed = await save(input, authorization.id); + assert.equal(renewed.status, "authorized"); + assert.equal( + ( + await request(`/companies/${companyId}/people/${person.id}/update`, { + ...personValues, + active: false, + }) + ).status, + 200 + ); + const reviewed = (await list()).find((item) => item.id === authorization.id); + assert.ok(reviewed); + assert.equal(reviewed.needsReview, true); + assert.equal(reviewed.effective, false); + await error({ ...input, notes: "Extender" }, "TELEWORK_PERSON_INACTIVE", authorization.id); + const revoked = await save({ ...input, active: false, notes: "Salida" }, authorization.id); + assert.equal(revoked.status, "revoked"); + assert.equal(revoked.needsReview, false); + assert.equal(revoked.history.length, 6); + assert.equal(revoked.history[0]?.values.active, true); + await error(input, "TELEWORK_PERSON_INACTIVE", authorization.id); + assert.equal( + (await request(`/companies/${companyId}/people/${person.id}/update`, personValues)).status, + 200 + ); + await error(input, "TELEWORK_ALREADY_EXISTS"); + const reactivated = await save({ ...input, notes: "Regresa" }, authorization.id); + assert.equal(reactivated.effective, true); + assert.equal(reactivated.history.length, 7); + assert.ok(reactivated.history.every((revision) => revision.actorId !== person.id)); + await database.query("UPDATE telework_authorizations SET `values`=:values WHERE id=:id", { + replacements: { + id: authorization.id, + values: JSON.stringify({ ...reactivated.values, personId: randomUUID() }), + }, + }); + try { + const corrupt = await request(path); + assert.equal(corrupt.status, 500); + const body = await corrupt.text(); + assert.ok(!body.includes("SELECT")); + assert.ok(!body.includes("inconsistente")); + } finally { + await database.query("UPDATE telework_authorizations SET `values`=:values WHERE id=:id", { + replacements: { id: authorization.id, values: JSON.stringify(reactivated.values) }, + }); + } +} diff --git a/docs/arquitectura/modelo-ddd-e-identificadores.md b/docs/arquitectura/modelo-ddd-e-identificadores.md index e5e2849..2fc3721 100644 --- a/docs/arquitectura/modelo-ddd-e-identificadores.md +++ b/docs/arquitectura/modelo-ddd-e-identificadores.md @@ -74,3 +74,7 @@ ApplicationConfiguration es AggregateRoot; ApplicationConfigurationRevision es D ## Certificados por perfil CertificateConfiguration es AggregateRoot; CertificateConfigurationRevision es DomainEntity con UUID propio. CertificateConfigurationValues es Value Object inmutable con UniqueID para perfil/recurso y Maybe para ubicación/notas. Crear genera UUID v7, rehidratar conserva identidad e historial y editar compara semánticamente antes de añadir revisión. La caducidad se consulta a través del servicio público de Resources, sin duplicarla en el agregado. + +## Autorizaciones de teletrabajo + +TeleworkAuthorization es AggregateRoot y TeleworkAuthorizationRevision es DomainEntity, ambas con UniqueID. TeleworkAuthorizationValues encapsula persona UniqueID, fechas CalendarDate y ausencia Maybe. Renovar y revocar conservan identidad e historial; la vigencia y efectividad se calculan sin duplicar datos. Consultar [Teletrabajo](../teletrabajo.md). diff --git a/docs/cuentas-grupos-y-recursos.md b/docs/cuentas-grupos-y-recursos.md index 27f97b1..3736045 100644 --- a/docs/cuentas-grupos-y-recursos.md +++ b/docs/cuentas-grupos-y-recursos.md @@ -142,7 +142,7 @@ Pasan typecheck, lint, test, check y build. La compilación conserva el aviso pr ## Pendiente para futuras entregas -Configuración por ordenador o móvil, POP/IMAP, teletrabajo y acceso remoto, inventario y modelos de activos, procesos y plantillas de alta/cambio/salida, relación con licencias e integraciones con sistemas externos. +Los perfiles y sus configuraciones, el inventario y las plantillas de activos, y la [autorización de teletrabajo](teletrabajo.md) están implementados. Quedan pendientes las conexiones y destinos de acceso remoto, procesos y plantillas de alta/cambio/salida, relación con licencias e integraciones con sistemas externos. ## Migración de recursos básicos diff --git a/docs/entorno-actual.md b/docs/entorno-actual.md index 216fb83..fcf9260 100644 --- a/docs/entorno-actual.md +++ b/docs/entorno-actual.md @@ -31,3 +31,7 @@ Pruebas de proyección e interfaz: varios buzones, aislamiento por compañía y Comprobaciones de esta entrega: 122 pruebas pasan, incluidos los cinco casos nuevos de la ficha y la integración con MariaDB de test. Typecheck y build pasan. Los siete archivos de código y configuración de este bloque pasan Biome. Lint y check globales mantienen los errores previos (32 y 90 respectivamente), sin nuevos errores de este bloque. En navegador se comprueban los datos reales del perfil, el filtro de atención, las consultas de correos y aplicaciones, el regreso al mismo perfil y el formulario modal con perfil fijo y cancelación. No se crean registros ficticios en desarrollo. + +## Teletrabajo de la persona + +La ficha consulta también la [autorización de teletrabajo](teletrabajo.md), común a los perfiles de la persona y separada de las configuraciones del dispositivo. Muestra vigencia y revisión o ausencia de registro; permite gestionar la autorización y volver al mismo perfil. diff --git a/docs/inventario-de-activos.md b/docs/inventario-de-activos.md index e0cda43..487a260 100644 --- a/docs/inventario-de-activos.md +++ b/docs/inventario-de-activos.md @@ -42,4 +42,4 @@ GET /api/assets/:companyId consulta el inventario. POST /api/assets/:companyId y Las pruebas verifican características opcionales y obligatorias, tipos, fechas, cero y falso, UUID, igualdad semántica, aislamiento por compañía, historial, desactivaciones, rehidratación y protección de las escrituras. La comprobación en navegador incluye alta rápida, cancelación, conservación de fechas y consulta del historial. -Las [relaciones entre activos](relaciones-entre-activos.md) están disponibles en ambas fichas. Los [puestos de trabajo](puestos-de-trabajo.md) están disponibles como agrupaciones sin anidación. Configuración por dispositivo, teletrabajo, inventario de dispositivos personales y procesos de salida mantienen su orden en el plan. +Las [relaciones entre activos](relaciones-entre-activos.md) están disponibles en ambas fichas. Los [puestos de trabajo](puestos-de-trabajo.md) están disponibles como agrupaciones sin anidación. Los perfiles y sus configuraciones están implementados, así como la [autorización de teletrabajo](teletrabajo.md). La conectividad remota, el registro mínimo de equipos personales y los procesos de salida mantienen su orden en el plan. diff --git a/docs/personas-y-departamentos.md b/docs/personas-y-departamentos.md index eccc89c..73949a0 100644 --- a/docs/personas-y-departamentos.md +++ b/docs/personas-y-departamentos.md @@ -58,4 +58,4 @@ 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. -Pendiente: configuración por dispositivos, teletrabajo, procesos y plantillas e inventario de activos. Los datos de estas funciones no se crean todavía. +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. diff --git a/docs/plan-de-desarrollo.md b/docs/plan-de-desarrollo.md index fae1c67..fdfab66 100644 --- a/docs/plan-de-desarrollo.md +++ b/docs/plan-de-desarrollo.md @@ -67,9 +67,13 @@ Recursos de plantilla Certificado digital o plantilla personalizada con ese uso, Ficha de consulta por perfil con equipo, características registradas, composición de los puestos vigentes, cuenta de inicio y configuraciones abiertas de correo, carpetas, impresoras, aplicaciones y certificados. Resumen de preparación, retirada y revisión; filtro de atención y acceso a cada gestión. Consultar [Entorno actual](entorno-actual.md). +## Teletrabajo: autorización implementada + +Autorización por persona, fechas opcionales inclusivas, revocación y renovación con historial, consulta desde Entorno actual y modales con alta rápida de persona. No modifica los permisos ni las conexiones reales. Consultar [Teletrabajo](teletrabajo.md). + ## Siguientes entregas: orden actualizado -1. Teletrabajo y accesos remotos: autorización, equipo de origen, conectividad VPN y destino. Asistencia técnica remota independiente. +1. Accesos remotos: equipo de origen corporativo o personal sin inventario detallado, conectividad VPN y destino (equipo o recurso). La autorización de teletrabajo ya está implementada. Asistencia técnica remota independiente. 2. Procesos de alta, cambio y salida con seguimiento de configuraciones y retiradas. Plantillas reutilizables entre compañías; ejecuciones y datos propios. Este orden se concreta antes de cada entrega. Las licencias y su relación con cuentas se explorarán posteriormente. diff --git a/docs/teletrabajo.md b/docs/teletrabajo.md new file mode 100644 index 0000000..aa3f1f5 --- /dev/null +++ b/docs/teletrabajo.md @@ -0,0 +1,55 @@ +# Teletrabajo: autorización por persona + +Primera parte del bloque de teletrabajo y accesos remotos. Documenta la autorización para trabajar fuera de la sede. Los equipos de origen, las conexiones VPN, los destinos RDP y la asistencia técnica remota todavía no se registran en esta sección. + +## Reglas + +Una autorización pertenece a una persona de una compañía y tiene UUID propio. No depende de un perfil o un equipo: es común a los perfiles de esa persona. Las personas no acceden a Metis Desk. + +Se conserva una sola autorización por persona, incluso al revocarla. Renovar, revocar o reactivar se realiza editando el mismo registro y conservando todas sus revisiones. La persona no se puede cambiar. No hay borrado. + +Los campos son persona, autorización activa, fecha inicial opcional, fecha final opcional y notas opcionales. Sin fechas no hay límite temporal. Si se indican ambas, la inicial no puede ser posterior a la final. Ambos días están incluidos. La fecha actual se calcula en el servidor en UTC, como las demás caducidades del sistema. + +El estado se calcula en lectura: Revocada si está desactivada; Caducada si ha pasado la fecha final; Programada si aún no ha llegado la fecha inicial; Autorizada en los demás casos. La autorización solo es efectiva cuando está Autorizada y la persona está activa. No se persiste una segunda copia del estado calculado. + +No se autoriza ni reactiva una persona inactiva. Una autorización activa de una persona que se desactiva se conserva y muestra revisión, sin ser efectiva. Puede revocarse; una repetición idéntica sigue siendo idempotente. Para modificar notas de una autorización conservada de una persona inactiva, revocarla o reactivar previamente a la persona. + +Revocar no cierra una VPN ni desinstala o retira configuraciones reales. Sirve como información documentada para revisar los accesos existentes. No se eliminan correos, permisos o configuraciones por desactivar la autorización. + +## Interfaz + +Sección Teletrabajo dentro del espacio de compañía: búsqueda por persona, consulta de vigencia, edición e historial. Nueva autorización en la cabecera derecha; alta y edición en modal. La ayuda y los ejemplos aparecen inmediatamente antes del pie con Guardar y Cancelar. + +La persona utiliza SelectWithAction con alta rápida en un segundo modal. No se abre un tercer nivel. Cancelar la persona conserva el formulario padre. Una persona ya guardada se conserva aunque se cancele la autorización. + +Entorno actual consulta la autorización de la persona, separada de los cinco bloques de configuración. Si no existe, lo indica sin presuponer permiso. Gestionar teletrabajo abre una consulta filtrada de esa persona; un alta iniciada desde el perfil la deja fija. Si ya hay autorización, se edita en lugar de crear otra. Volver al entorno conserva el perfil seleccionado. + +Los listados no tienen formularios visibles junto a los registros. Traducciones es/en, carga, error, vacío, reintento y acciones deshabilitadas durante escritura. + +## Dominio y persistencia + +TeleworkAuthorization es AggregateRoot; TeleworkAuthorizationRevision es DomainEntity con UUID propio; TeleworkAuthorizationValues encapsula referencias UniqueID, fechas CalendarDate y opcionales Maybe. + +Crear genera UUID v7. Update conserva la identidad y compara valores semánticamente: una repetición no crea revisión. El destino persona es fijo y las revisiones conservan actor de sesión y fecha UTC. Rehydrate reconstruye identidades e historial sin emitir eventos. + +Migraciones explícitas e idempotentes para telework_authorizations y telework_authorization_revisions. FK a compañía, persona, autorización y actor. Persona única; posición de revisión única dentro de cada autorización. + +Las escrituras bloquean la compañía antes de leer, validar y guardar. Las altas concurrentes no crean dos autorizaciones. El mapper bidireccional detecta referencias incoherentes, historial vacío, posiciones o fechas inválidas, destino alterado y discrepancia entre raíz y última revisión. Se valida pertenencia a compañía también al leer; los fallos de persistencia no exponen SQL ni datos internos. + +## HTTP + +GET /api/telework-authorizations/:companyId devuelve autorizaciones, estado calculado, efectividad, revisión e historial. + +POST en la misma ruta crea. POST /api/telework-authorizations/:companyId/:id/update modifica. Entrada estricta Zod y UUID. Sesión obligatoria; origen y CSRF en escrituras. Actor obtenido de sesión, nunca del formulario. No se pueden leer ni actualizar por otra compañía. + +## Pruebas + +Dominio: fechas y ausencia, límites, contrato estricto, UUID, igualdad semántica, actualización idempotente, destino fijo, historial y renovación, vigencia inclusiva, caducidad, programación, unicidad, persona inactiva y revocación, rehidratación corrupta. + +HTTP con MariaDB de test: persistencia, origen/CSRF, sesión, aislamiento, alta concurrente, actor autenticado, historial, fechas límite, revocación, renovación y fallo cerrado ante corrupción. + +Interfaz: listado sin formularios, traducciones, alta rápida, ayuda inferior, campos opcionales, persona fija al iniciar desde perfil, avisos de revisión y autorización común a perfiles. + +La comprobación en navegador verifica selector de persona, alta rápida y retorno, formulario padre conservado, ayuda antes del pie, cancelación, consulta desde Entorno actual y persona fija desde el perfil. Se corrige la actualización de los calendarios con su evento de entrada: las fechas se conservan al editar notas y el periodo inválido se rechaza en el modal. El registro temporal creado durante la comprobación se retiró; no quedan datos ficticios de teletrabajo en desarrollo. + +Validación final: 132 pruebas pasan en el monorepo, incluidos diez casos nuevos de dominio/interfaz y la integración HTTP con MariaDB de test. Se repite la suite completa tras corregir el calendario y la suite de Companies después del ajuste de textos. Typecheck y build pasan. Los 33 archivos de código y configuración de este bloque pasan Biome; lint y check globales mantienen los errores anteriores (32 y 90), sin errores nuevos del bloque. diff --git a/modules/companies/package.json b/modules/companies/package.json index 683e9ee..8b5aecf 100644 --- a/modules/companies/package.json +++ b/modules/companies/package.json @@ -10,7 +10,7 @@ }, "scripts": { "typecheck": "tsc -p tsconfig.json", - "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/components/companies-page.test.tsx src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/api/domain/mailbox.test.ts src/web/access/access.test.tsx src/web/mail/mail.test.tsx src/api/domain/work-profile.test.ts src/web/profiles/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printers/printer.test.tsx src/api/domain/application-configuration.test.ts src/web/applications/application.test.tsx src/api/domain/certificate-configuration.test.ts src/web/certificates/certificate.test.tsx src/web/profiles/work-environment.test.tsx" + "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" }, "dependencies": { "@repo/rdx-ddd": "workspace:*", diff --git a/modules/companies/src/api/application/telework-authorization-input.mapper.ts b/modules/companies/src/api/application/telework-authorization-input.mapper.ts new file mode 100644 index 0000000..476820f --- /dev/null +++ b/modules/companies/src/api/application/telework-authorization-input.mapper.ts @@ -0,0 +1,46 @@ +import { Result } from "@repo/rdx-utils"; + +import type { TeleworkAuthorization } from "../domain/telework-authorization.aggregate"; +import { + TeleworkAuthorizationError, + type TeleworkAuthorizationInput, +} from "../domain/telework-authorization-values"; +import { TeleworkAuthorizationValues } from "../domain/value-objects/telework-authorization-values"; + +import type { TeleworkAuthorizationContext } from "./telework-authorization.repository"; + +export function mapTeleworkAuthorizationInput( + input: TeleworkAuthorizationInput, + context: TeleworkAuthorizationContext, + previous?: TeleworkAuthorization +) { + const mapped = TeleworkAuthorizationValues.create(input); + if (!mapped.isSuccess) { + return mapped; + } + const values = mapped.data; + if (previous && !previous.values.sameTarget(values)) { + return Result.fail(new TeleworkAuthorizationError("TELEWORK_PERSON_IMMUTABLE")); + } + const person = context.people.find((item) => item.id.equals(values.personId)); + if (!person) { + return Result.fail(new TeleworkAuthorizationError("TELEWORK_PERSON_NOT_FOUND")); + } + // Se permite revocar y anotar una autorización de una persona inactiva, sin ocultar sus accesos pendientes. + if ( + values.active && + !person.active && + !(previous?.values.active && previous.values.equals(values)) + ) { + return Result.fail(new TeleworkAuthorizationError("TELEWORK_PERSON_INACTIVE")); + } + if ( + context.authorizations.some( + (item) => + item.id.toPrimitive() !== previous?.id.toPrimitive() && item.values.sameTarget(values) + ) + ) { + return Result.fail(new TeleworkAuthorizationError("TELEWORK_ALREADY_EXISTS")); + } + return mapped; +} diff --git a/modules/companies/src/api/application/telework-authorization-policy.ts b/modules/companies/src/api/application/telework-authorization-policy.ts new file mode 100644 index 0000000..de8dff6 --- /dev/null +++ b/modules/companies/src/api/application/telework-authorization-policy.ts @@ -0,0 +1,25 @@ +import type { TeleworkAuthorizationDTO } from "../../common/telework-authorizations"; +import type { TeleworkAuthorization } from "../domain/telework-authorization.aggregate"; + +import type { TeleworkAuthorizationContext } from "./telework-authorization.repository"; +export function teleworkAuthorizationState( + item: TeleworkAuthorization, + context: TeleworkAuthorizationContext +): Pick { + const values = item.values.toPrimitive(); + const available = context.people.some( + (person) => person.id.equals(item.values.personId) && person.active + ); + const status = values.active + ? values.expiresOn && values.expiresOn < context.today + ? "expired" + : values.startsOn && values.startsOn > context.today + ? "scheduled" + : "authorized" + : "revoked"; + return { + status, + effective: status === "authorized" && available, + needsReview: values.active && !available, + }; +} diff --git a/modules/companies/src/api/application/telework-authorization-response.builder.ts b/modules/companies/src/api/application/telework-authorization-response.builder.ts new file mode 100644 index 0000000..d39461f --- /dev/null +++ b/modules/companies/src/api/application/telework-authorization-response.builder.ts @@ -0,0 +1,28 @@ +import type { TeleworkAuthorizationDTO } from "../../common/telework-authorizations"; +import type { TeleworkAuthorization } from "../domain/telework-authorization.aggregate"; + +import type { TeleworkAuthorizationContext } from "./telework-authorization.repository"; +import { teleworkAuthorizationState } from "./telework-authorization-policy"; + +export function buildTeleworkAuthorizationResponse( + item: TeleworkAuthorization, + context: TeleworkAuthorizationContext +): TeleworkAuthorizationDTO { + const { status, effective, needsReview } = teleworkAuthorizationState(item, context); + return { + id: item.id.toPrimitive(), + companyId: item.companyId.toPrimitive(), + values: item.values.toPrimitive(), + status, + effective, + needsReview, + history: item.history.map((revision) => ({ + id: revision.id.toPrimitive(), + values: revision.values.toPrimitive(), + actorId: revision.actorId.toPrimitive(), + actorName: + context.actors.get(revision.actorId.toPrimitive()) ?? revision.actorId.toPrimitive(), + recordedAt: revision.recordedAt, + })), + }; +} diff --git a/modules/companies/src/api/application/telework-authorization.repository.ts b/modules/companies/src/api/application/telework-authorization.repository.ts new file mode 100644 index 0000000..8b997e8 --- /dev/null +++ b/modules/companies/src/api/application/telework-authorization.repository.ts @@ -0,0 +1,28 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import type { Result } from "@repo/rdx-utils"; + +import type { TeleworkAuthorization } from "../domain/telework-authorization.aggregate"; +import type { TeleworkAuthorizationError } from "../domain/telework-authorization-values"; + +export type TeleworkAuthorizationContext = Readonly<{ + authorizations: readonly TeleworkAuthorization[]; + people: readonly { id: UniqueID; active: boolean }[]; + actors: ReadonlyMap; + today: string; +}>; +export interface ITeleworkAuthorizationRepository { + get( + companyId: UniqueID + ): Promise>; + save( + companyId: UniqueID, + action: ( + context: TeleworkAuthorizationContext + ) => Result + ): Promise< + Result< + { authorization: TeleworkAuthorization; context: TeleworkAuthorizationContext }, + TeleworkAuthorizationError + > + >; +} diff --git a/modules/companies/src/api/application/telework-authorization.use-cases.ts b/modules/companies/src/api/application/telework-authorization.use-cases.ts new file mode 100644 index 0000000..f686608 --- /dev/null +++ b/modules/companies/src/api/application/telework-authorization.use-cases.ts @@ -0,0 +1,47 @@ +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { TeleworkAuthorization } from "../domain/telework-authorization.aggregate"; +import type { TeleworkAuthorizationInput } from "../domain/telework-authorization-values"; +import { TeleworkAuthorizationError } from "../domain/telework-authorization-values"; + +import type { ITeleworkAuthorizationRepository } from "./telework-authorization.repository"; +import { mapTeleworkAuthorizationInput } from "./telework-authorization-input.mapper"; +export class TeleworkAuthorizationUseCases { + constructor(private readonly repository: ITeleworkAuthorizationRepository) {} + get(companyId: string) { + const company = UniqueID.create(companyId); + return company.isSuccess + ? this.repository.get(company.data) + : Promise.resolve(Result.fail(new TeleworkAuthorizationError("INVALID_REQUEST"))); + } + save( + companyId: string, + id: string | undefined, + input: TeleworkAuthorizationInput, + actorId: string + ) { + const company = UniqueID.create(companyId); + const actor = UniqueID.create(actorId); + const identity = id ? UniqueID.create(id) : undefined; + if (!(company.isSuccess && actor.isSuccess) || (identity && !identity.isSuccess)) { + return Promise.resolve(Result.fail(new TeleworkAuthorizationError("INVALID_REQUEST"))); + } + return this.repository.save(company.data, (context) => { + const previous = identity?.isSuccess + ? context.authorizations.find((item) => item.id.equals(identity.data)) + : undefined; + if (id && !previous) { + return Result.fail(new TeleworkAuthorizationError("TELEWORK_AUTHORIZATION_NOT_FOUND")); + } + const values = mapTeleworkAuthorizationInput(input, context, previous); + if (!values.isSuccess) { + return Result.fail(values.error); + } + const now = new Date().toISOString(); + return previous + ? previous.update(values.data, actor.data, now) + : TeleworkAuthorization.create(company.data, values.data, actor.data, now); + }); + } +} diff --git a/modules/companies/src/api/domain/telework-authorization-revision.entity.ts b/modules/companies/src/api/domain/telework-authorization-revision.entity.ts new file mode 100644 index 0000000..52fde28 --- /dev/null +++ b/modules/companies/src/api/domain/telework-authorization-revision.entity.ts @@ -0,0 +1,25 @@ +import { DomainEntity, type UniqueID } from "@repo/rdx-ddd"; + +import type { TeleworkAuthorizationValues } from "./value-objects/telework-authorization-values"; +export type TeleworkAuthorizationRevisionProps = Readonly<{ + values: TeleworkAuthorizationValues; + actorId: UniqueID; + recordedAt: string; +}>; +export class TeleworkAuthorizationRevision extends DomainEntity { + static create(props: TeleworkAuthorizationRevisionProps) { + return new TeleworkAuthorizationRevision(Object.freeze({ ...props })); + } + static rehydrate(props: TeleworkAuthorizationRevisionProps, id: UniqueID) { + return new TeleworkAuthorizationRevision(Object.freeze({ ...props }), id); + } + get values() { + return this.props.values; + } + get actorId() { + return this.props.actorId; + } + get recordedAt() { + return this.props.recordedAt; + } +} diff --git a/modules/companies/src/api/domain/telework-authorization-values.ts b/modules/companies/src/api/domain/telework-authorization-values.ts new file mode 100644 index 0000000..6f6349e --- /dev/null +++ b/modules/companies/src/api/domain/telework-authorization-values.ts @@ -0,0 +1,20 @@ +import { z } from "zod/v4"; + +export const TeleworkAuthorizationInputSchema = z + .strictObject({ + personId: z.uuid(), + active: z.boolean(), + startsOn: z.iso.date().nullable(), + expiresOn: z.iso.date().nullable(), + notes: z.string().trim().max(4000).nullable(), + }) + .refine((value) => !(value.startsOn && value.expiresOn) || value.startsOn <= value.expiresOn, { + path: ["expiresOn"], + message: "INVALID_REQUEST", + }); +export type TeleworkAuthorizationInput = z.infer; +export class TeleworkAuthorizationError extends Error { + constructor(public readonly code: string) { + super(code); + } +} diff --git a/modules/companies/src/api/domain/telework-authorization.aggregate.ts b/modules/companies/src/api/domain/telework-authorization.aggregate.ts new file mode 100644 index 0000000..6591344 --- /dev/null +++ b/modules/companies/src/api/domain/telework-authorization.aggregate.ts @@ -0,0 +1,78 @@ +import { AggregateRoot, type UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import { z } from "zod/v4"; + +import { TeleworkAuthorizationRevision } from "./telework-authorization-revision.entity"; +import { TeleworkAuthorizationError } from "./telework-authorization-values"; +import type { TeleworkAuthorizationValues } from "./value-objects/telework-authorization-values"; + +type TeleworkAuthorizationProps = Readonly<{ + companyId: UniqueID; + values: TeleworkAuthorizationValues; + history: readonly TeleworkAuthorizationRevision[]; +}>; +export class TeleworkAuthorization extends AggregateRoot { + private constructor(props: TeleworkAuthorizationProps, id?: UniqueID) { + super(Object.freeze({ ...props, history: Object.freeze([...props.history]) }), id); + } + static create( + companyId: UniqueID, + values: TeleworkAuthorizationValues, + actorId: UniqueID, + recordedAt: string + ): Result { + if (!z.iso.datetime().safeParse(recordedAt).success) { + return Result.fail(new TeleworkAuthorizationError("INVALID_REQUEST")); + } + return Result.ok( + new TeleworkAuthorization({ + companyId, + values, + history: [TeleworkAuthorizationRevision.create({ values, actorId, recordedAt })], + }) + ); + } + static rehydrate(props: TeleworkAuthorizationProps, id: UniqueID) { + return new TeleworkAuthorization(props, id); + } + get companyId() { + return this.props.companyId; + } + get values() { + return this.props.values; + } + get history() { + return this.props.history; + } + update( + values: TeleworkAuthorizationValues, + actorId: UniqueID, + recordedAt: string + ): Result { + if (this.values.equals(values)) { + return Result.ok(this); + } + if (!this.values.sameTarget(values)) { + return Result.fail(new TeleworkAuthorizationError("TELEWORK_PERSON_IMMUTABLE")); + } + if ( + !z.iso.datetime().safeParse(recordedAt).success || + recordedAt < (this.history.at(-1)?.recordedAt ?? "") + ) { + return Result.fail(new TeleworkAuthorizationError("INVALID_REQUEST")); + } + return Result.ok( + new TeleworkAuthorization( + { + ...this.props, + values, + history: [ + ...this.history, + TeleworkAuthorizationRevision.create({ values, actorId, recordedAt }), + ], + }, + this.id + ) + ); + } +} diff --git a/modules/companies/src/api/domain/telework-authorization.test.ts b/modules/companies/src/api/domain/telework-authorization.test.ts new file mode 100644 index 0000000..441ca78 --- /dev/null +++ b/modules/companies/src/api/domain/telework-authorization.test.ts @@ -0,0 +1,183 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { UniqueID } from "@repo/rdx-ddd"; + +import type { TeleworkAuthorizationContext } from "../application/telework-authorization.repository"; +import { mapTeleworkAuthorizationInput } from "../application/telework-authorization-input.mapper"; +import { teleworkAuthorizationState } from "../application/telework-authorization-policy"; +import { TeleworkAuthorizationPersistenceMapper } from "../infrastructure/persistence/telework-authorization-persistence.mapper"; + +import { TeleworkAuthorization } from "./telework-authorization.aggregate"; +import { + type TeleworkAuthorizationInput, + TeleworkAuthorizationInputSchema, +} from "./telework-authorization-values"; +import { TeleworkAuthorizationValues } from "./value-objects/telework-authorization-values"; + +const company = UniqueID.generateNewID(); +const person = UniqueID.generateNewID(); +const actor = UniqueID.generateNewID(); +const input: TeleworkAuthorizationInput = { + personId: person.toPrimitive(), + active: true, + startsOn: null, + expiresOn: null, + notes: null, +}; +const now = "2026-10-06T09:00:00.000Z"; +const context: TeleworkAuthorizationContext = { + authorizations: [], + people: [{ id: person, active: true }], + actors: new Map([[actor.toPrimitive(), "Técnico"]]), + today: "2026-10-06", +}; +function values(value: TeleworkAuthorizationInput) { + const result = TeleworkAuthorizationValues.create(value); + assert.ok(result.isSuccess); + return result.data; +} +function create(value: TeleworkAuthorizationInput = input) { + const result = TeleworkAuthorization.create(company, values(value), actor, now); + assert.ok(result.isSuccess); + return result.data; +} +test("teletrabajo: ausencia, límites, fechas reales y contrato estricto", () => { + assert.ok(TeleworkAuthorizationInputSchema.safeParse(input).success); + assert.ok( + !TeleworkAuthorizationInputSchema.safeParse({ ...input, startsOn: "2026-02-30" }).success + ); + assert.ok( + !TeleworkAuthorizationInputSchema.safeParse({ + ...input, + startsOn: "2026-10-07", + expiresOn: "2026-10-06", + }).success + ); + assert.ok( + !TeleworkAuthorizationInputSchema.safeParse({ ...input, notes: "a".repeat(4001) }).success + ); + assert.ok(!TeleworkAuthorizationInputSchema.safeParse({ ...input, password: "secret" }).success); + assert.equal(values({ ...input, notes: " " }).toPrimitive().notes, null); + assert.equal(values({ ...input, notes: " Condiciones " }).toPrimitive().notes, "Condiciones"); +}); +test("teletrabajo: UUID, renovación y revocación auditadas, igualdad y destino fijo", () => { + const item = create(); + assert.equal(item.id.toPrimitive()[14], "7"); + assert.equal(item.history[0]?.id.toPrimitive()[14], "7"); + const repeated = item.update(values(input), actor, "invalid"); + assert.ok(repeated.isSuccess); + assert.equal(repeated.data, item); + const revoked = item.update( + values({ ...input, active: false, notes: "Salida" }), + actor, + "2026-10-07T09:00:00.000Z" + ); + assert.ok(revoked.isSuccess); + assert.ok(revoked.data.id.equals(item.id)); + assert.equal(revoked.data.history.length, 2); + assert.notEqual( + revoked.data.history[0]?.id.toPrimitive(), + revoked.data.history[1]?.id.toPrimitive() + ); + assert.equal(item.values.active, true); + const renewed = revoked.data.update( + values({ ...input, expiresOn: "2026-12-31" }), + actor, + "2026-10-08T09:00:00.000Z" + ); + assert.ok(renewed.isSuccess); + assert.equal(renewed.data.history.length, 3); + const foreign = item.update( + values({ ...input, personId: UniqueID.generateNewID().toPrimitive() }), + actor, + now + ); + assert.ok(!foreign.isSuccess); + assert.equal(foreign.error.code, "TELEWORK_PERSON_IMMUTABLE"); + assert.ok( + !item.update(values({ ...input, active: false }), actor, "2026-10-05T09:00:00.000Z").isSuccess + ); + assert.ok(!TeleworkAuthorization.create(company, values(input), actor, "invalid").isSuccess); +}); +test("teletrabajo: vigencia inclusiva, programación, caducidad y persona inactiva", () => { + const day = { ...input, startsOn: "2026-10-06", expiresOn: "2026-10-06" }; + assert.deepEqual(teleworkAuthorizationState(create(day), context), { + status: "authorized", + effective: true, + needsReview: false, + }); + assert.equal( + teleworkAuthorizationState(create(day), { ...context, today: "2026-10-05" }).status, + "scheduled" + ); + assert.equal( + teleworkAuthorizationState(create(day), { ...context, today: "2026-10-07" }).status, + "expired" + ); + assert.equal( + teleworkAuthorizationState(create({ ...day, active: false }), context).status, + "revoked" + ); + assert.deepEqual( + teleworkAuthorizationState(create(), { ...context, people: [{ id: person, active: false }] }), + { status: "authorized", effective: false, needsReview: true } + ); +}); +test("teletrabajo: ámbito, unicidad incluso revocada, disponibilidad y retirada", () => { + const missing = mapTeleworkAuthorizationInput(input, { ...context, people: [] }); + assert.ok(!missing.isSuccess); + assert.equal(missing.error.code, "TELEWORK_PERSON_NOT_FOUND"); + const inactive = { ...context, people: [{ id: person, active: false }] }; + const rejected = mapTeleworkAuthorizationInput(input, inactive); + assert.ok(!rejected.isSuccess); + assert.equal(rejected.error.code, "TELEWORK_PERSON_INACTIVE"); + const item = create(); + assert.ok(mapTeleworkAuthorizationInput({ ...input, active: false }, inactive, item).isSuccess); + assert.ok(mapTeleworkAuthorizationInput(input, inactive, item).isSuccess); + const duplicate = mapTeleworkAuthorizationInput(input, { + ...context, + authorizations: [create({ ...input, active: false })], + }); + assert.ok(!duplicate.isSuccess); + assert.equal(duplicate.error.code, "TELEWORK_ALREADY_EXISTS"); + const foreign = mapTeleworkAuthorizationInput( + { ...input, personId: UniqueID.generateNewID().toPrimitive() }, + context, + item + ); + assert.ok(!foreign.isSuccess); + assert.equal(foreign.error.code, "TELEWORK_PERSON_IMMUTABLE"); +}); +test("teletrabajo: rehidratación preserva identidad y detecta corrupción de raíz e historial", () => { + const mapper = new TeleworkAuthorizationPersistenceMapper(); + const item = create(); + const record = mapper.toPersistence(item); + const restored = mapper.toDomain(record.root, record.revisions); + assert.ok(restored.id.equals(item.id)); + assert.ok(restored.values.equals(item.values)); + assert.ok(item.history[0]); + assert.ok(restored.history[0]?.id.equals(item.history[0].id)); + assert.throws(() => mapper.toDomain(record.root, [])); + assert.throws(() => + mapper.toDomain({ ...record.root, personId: actor.toPrimitive() }, record.revisions) + ); + assert.throws(() => + mapper.toDomain( + record.root, + record.revisions.map((r) => ({ ...r, authorizationId: actor.toPrimitive() })) + ) + ); + assert.throws(() => + mapper.toDomain( + record.root, + record.revisions.map((r) => ({ ...r, position: 1 })) + ) + ); + assert.throws(() => + mapper.toDomain( + record.root, + record.revisions.map((r) => ({ ...r, recordedAt: "invalid" })) + ) + ); +}); diff --git a/modules/companies/src/api/domain/value-objects/telework-authorization-values.ts b/modules/companies/src/api/domain/value-objects/telework-authorization-values.ts new file mode 100644 index 0000000..c7af92e --- /dev/null +++ b/modules/companies/src/api/domain/value-objects/telework-authorization-values.ts @@ -0,0 +1,68 @@ +import { UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { + TeleworkAuthorizationError, + type TeleworkAuthorizationInput, + TeleworkAuthorizationInputSchema, +} from "../telework-authorization-values"; + +import { CalendarDate } from "./calendar-date"; + +export class TeleworkAuthorizationValues { + private constructor( + public readonly personId: UniqueID, + public readonly active: boolean, + public readonly startsOn: Maybe, + public readonly expiresOn: Maybe, + public readonly notes: Maybe + ) { + Object.freeze(this); + } + static create( + input: TeleworkAuthorizationInput + ): Result { + const parsed = TeleworkAuthorizationInputSchema.safeParse(input); + if (!parsed.success) { + return Result.fail(new TeleworkAuthorizationError("INVALID_REQUEST")); + } + const person = UniqueID.create(parsed.data.personId); + const start = parsed.data.startsOn ? CalendarDate.create(parsed.data.startsOn) : undefined; + const expiry = parsed.data.expiresOn ? CalendarDate.create(parsed.data.expiresOn) : undefined; + if (!person.isSuccess || (start && !start.isSuccess) || (expiry && !expiry.isSuccess)) { + return Result.fail(new TeleworkAuthorizationError("INVALID_REQUEST")); + } + return Result.ok( + new TeleworkAuthorizationValues( + person.data, + parsed.data.active, + start?.isSuccess ? Maybe.some(start.data) : Maybe.none(), + expiry?.isSuccess ? Maybe.some(expiry.data) : Maybe.none(), + parsed.data.notes ? Maybe.some(parsed.data.notes) : Maybe.none() + ) + ); + } + toPrimitive(): TeleworkAuthorizationInput { + return { + personId: this.personId.toPrimitive(), + active: this.active, + startsOn: this.startsOn.getOrUndefined()?.toPrimitive() ?? null, + expiresOn: this.expiresOn.getOrUndefined()?.toPrimitive() ?? null, + notes: this.notes.getOrUndefined() ?? null, + }; + } + sameTarget(other: TeleworkAuthorizationValues) { + return this.personId.equals(other.personId); + } + equals(other: TeleworkAuthorizationValues) { + const a = this.toPrimitive(); + const b = other.toPrimitive(); + return ( + this.sameTarget(other) && + a.active === b.active && + a.startsOn === b.startsOn && + a.expiresOn === b.expiresOn && + a.notes === b.notes + ); + } +} diff --git a/modules/companies/src/api/infrastructure/http/telework-authorization.routes.ts b/modules/companies/src/api/infrastructure/http/telework-authorization.routes.ts new file mode 100644 index 0000000..e02d47e --- /dev/null +++ b/modules/companies/src/api/infrastructure/http/telework-authorization.routes.ts @@ -0,0 +1,84 @@ +import { type Application, type RequestHandler, type Response, Router } from "express"; +import { z } from "zod/v4"; + +import { + SaveTeleworkAuthorizationRequestSchema, + TeleworkItemParamsSchema, + TeleworkParamsSchema, +} from "../../../common/telework-authorizations"; +import type { TeleworkAuthorizationUseCases } from "../../application/telework-authorization.use-cases"; +import { buildTeleworkAuthorizationResponse } from "../../application/telework-authorization-response.builder"; + +function fail(response: Response, code: string) { + response + .status(code === "INVALID_REQUEST" ? 400 : code.endsWith("NOT_FOUND") ? 404 : 409) + .json({ code, requestId: response.getHeader("x-request-id") }); +} +export function registerTeleworkAuthorizationRoutes( + app: Application, + useCases: TeleworkAuthorizationUseCases, + authenticate: RequestHandler +) { + const router = Router(); + router.get("/:companyId", (request, response, next) => { + const params = TeleworkParamsSchema.safeParse(request.params); + if (!(params.success && z.strictObject({}).safeParse(request.query).success)) { + fail(response, "INVALID_REQUEST"); + return; + } + useCases + .get(params.data.companyId) + .then((result) => { + if (!result.isSuccess) { + fail(response, result.error.code); + return; + } + response.json({ + authorizations: result.data.authorizations.map((item) => + buildTeleworkAuthorizationResponse(item, result.data) + ), + }); + }) + .catch(next); + }); + for (const path of ["/:companyId", "/:companyId/:id/update"]) { + router.post(path, (request, response, next) => { + const params = ( + path.endsWith("update") ? TeleworkItemParamsSchema : TeleworkParamsSchema + ).safeParse(request.params); + const body = SaveTeleworkAuthorizationRequestSchema.safeParse(request.body); + const actor = z + .object({ userId: z.uuid() }) + .safeParse("session" in request ? request.session : undefined); + if ( + !( + params.success && + body.success && + actor.success && + z.strictObject({}).safeParse(request.query).success + ) + ) { + fail(response, "INVALID_REQUEST"); + return; + } + const id = + "id" in params.data && typeof params.data.id === "string" ? params.data.id : undefined; + useCases + .save(params.data.companyId, id, body.data.values, actor.data.userId) + .then((result) => { + if (!result.isSuccess) { + fail(response, result.error.code); + return; + } + response.status(id ? 200 : 201).json({ + authorization: buildTeleworkAuthorizationResponse( + result.data.authorization, + result.data.context + ), + }); + }) + .catch(next); + }); + } + app.use("/api/telework-authorizations", authenticate, router); +} diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize-telework-authorization.repository.ts b/modules/companies/src/api/infrastructure/persistence/sequelize-telework-authorization.repository.ts new file mode 100644 index 0000000..699d81a --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/sequelize-telework-authorization.repository.ts @@ -0,0 +1,109 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import { QueryTypes, type Sequelize, type Transaction } from "sequelize"; + +import type { + ITeleworkAuthorizationRepository, + TeleworkAuthorizationContext, +} from "../../application/telework-authorization.repository"; +import { TeleworkAuthorizationError } from "../../domain/telework-authorization-values"; + +import { + TeleworkAuthorizationPersistenceMapper, + type TeleworkRevisionRow, + type TeleworkRow, + teleworkIdentity, +} from "./telework-authorization-persistence.mapper"; + +export class SequelizeTeleworkAuthorizationRepository implements ITeleworkAuthorizationRepository { + private readonly mapper = new TeleworkAuthorizationPersistenceMapper(); + constructor(private readonly database: Sequelize) {} + private async load( + company: UniqueID, + transaction: Transaction, + write: boolean + ): Promise> { + const companyId = company.toPrimitive(); + // El bloqueo compartido de compañía serializa las altas y los cambios de personas. + const companies = await this.database.query<{ id: string }>( + `SELECT id FROM companies WHERE id=:companyId${write ? " FOR UPDATE" : ""}`, + { replacements: { companyId }, transaction, type: QueryTypes.SELECT } + ); + if (!companies.length) { + return Result.fail(new TeleworkAuthorizationError("COMPANY_NOT_FOUND")); + } + const rows = await this.database.query( + "SELECT * FROM telework_authorizations WHERE companyId=:companyId", + { replacements: { companyId }, transaction, type: QueryTypes.SELECT } + ); + const revisions = await this.database.query( + "SELECT r.* FROM telework_authorization_revisions r JOIN telework_authorizations a ON a.id=r.authorizationId WHERE a.companyId=:companyId", + { replacements: { companyId }, transaction, type: QueryTypes.SELECT } + ); + const people = await this.database.query<{ id: string; active: boolean }>( + "SELECT id,active FROM company_contacts WHERE companyId=:companyId", + { replacements: { companyId }, transaction, type: QueryTypes.SELECT } + ); + const actors = await this.database.query<{ id: string; name: string }>( + "SELECT id,name FROM users", + { transaction, type: QueryTypes.SELECT } + ); + const authorizations = rows.map((row) => + this.mapper.toDomain( + row, + revisions.filter((revision) => revision.authorizationId === row.id) + ) + ); + if ( + authorizations.some( + (item) => !people.some((person) => person.id === item.values.personId.toPrimitive()) + ) + ) { + throw new Error("Pertenencia de teletrabajo persistida inconsistente."); + } + return Result.ok({ + authorizations, + people: people.map((person) => ({ + id: teleworkIdentity(person.id), + active: Boolean(person.active), + })), + actors: new Map(actors.map((actor) => [actor.id, actor.name])), + today: new Date().toISOString().slice(0, 10), + }); + } + get(companyId: UniqueID) { + return this.database.transaction((transaction) => this.load(companyId, transaction, false)); + } + save(companyId: UniqueID, action: Parameters[1]) { + return this.database.transaction(async (transaction) => { + const context = await this.load(companyId, transaction, true); + if (!context.isSuccess) { + return context; + } + const candidate = action(context.data); + if (!candidate.isSuccess) { + return candidate; + } + const previous = context.data.authorizations.find((item) => + item.id.equals(candidate.data.id) + ); + if (previous !== candidate.data) { + const record = this.mapper.toPersistence(candidate.data); + await this.database.query( + "INSERT INTO telework_authorizations (id,companyId,personId,`values`) VALUES (:id,:companyId,:personId,:values) ON DUPLICATE KEY UPDATE `values`=VALUES(`values`)", + { replacements: record.root, transaction } + ); + const stored = new Set( + previous?.history.map((revision) => revision.id.toPrimitive()) ?? [] + ); + for (const revision of record.revisions.filter((item) => !stored.has(item.id))) { + await this.database.query( + "INSERT INTO telework_authorization_revisions (id,authorizationId,position,actorId,recordedAt,`values`) VALUES (:id,:authorizationId,:position,:actorId,:recordedAt,:values)", + { replacements: revision, transaction } + ); + } + } + return Result.ok({ authorization: candidate.data, context: context.data }); + }); + } +} diff --git a/modules/companies/src/api/infrastructure/persistence/telework-authorization-migration.ts b/modules/companies/src/api/infrastructure/persistence/telework-authorization-migration.ts new file mode 100644 index 0000000..ca50c30 --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/telework-authorization-migration.ts @@ -0,0 +1,42 @@ +import { DataTypes, QueryTypes, type Sequelize } from "sequelize"; +export async function migrateTeleworkAuthorizations(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 reference = (table: string) => ({ + type: DataTypes.UUID, + allowNull: false, + references: { model: table, key: "id" }, + onDelete: "RESTRICT", + }); + if (!tables.some((row) => row.name === "telework_authorizations")) { + await query.createTable("telework_authorizations", { + id: { type: DataTypes.UUID, primaryKey: true }, + companyId: reference("companies"), + personId: { ...reference("company_contacts"), unique: true }, + values: { type: DataTypes.JSON, allowNull: false }, + }); + } + if (!tables.some((row) => row.name === "telework_authorization_revisions")) { + await query.createTable("telework_authorization_revisions", { + id: { type: DataTypes.UUID, primaryKey: true }, + authorizationId: reference("telework_authorizations"), + position: { type: DataTypes.INTEGER, allowNull: false }, + actorId: reference("users"), + recordedAt: { type: DataTypes.STRING(24), allowNull: false }, + values: { type: DataTypes.JSON, allowNull: false }, + }); + } + const indexes = await database.query<{ name: string }>( + "SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='telework_authorization_revisions'", + { type: QueryTypes.SELECT } + ); + if (!indexes.some((item) => item.name === "telework_revision_position")) { + await query.addIndex("telework_authorization_revisions", ["authorizationId", "position"], { + unique: true, + name: "telework_revision_position", + }); + } +} diff --git a/modules/companies/src/api/infrastructure/persistence/telework-authorization-persistence.mapper.ts b/modules/companies/src/api/infrastructure/persistence/telework-authorization-persistence.mapper.ts new file mode 100644 index 0000000..068a6a2 --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/telework-authorization-persistence.mapper.ts @@ -0,0 +1,83 @@ +import { UniqueID } from "@repo/rdx-ddd"; +import { z } from "zod/v4"; + +import { TeleworkAuthorization } from "../../domain/telework-authorization.aggregate"; +import { TeleworkAuthorizationRevision } from "../../domain/telework-authorization-revision.entity"; +import { TeleworkAuthorizationInputSchema } from "../../domain/telework-authorization-values"; +import { TeleworkAuthorizationValues } from "../../domain/value-objects/telework-authorization-values"; + +export function teleworkIdentity(value: string) { + const id = UniqueID.create(value); + if (!id.isSuccess) { + throw new Error("Identidad de teletrabajo persistida inconsistente."); + } + return id.data; +} +export type TeleworkRow = { id: string; companyId: string; personId: string; values: unknown }; +export type TeleworkRevisionRow = { + id: string; + authorizationId: string; + position: number; + actorId: string; + recordedAt: string; + values: unknown; +}; +function valuesOf(raw: unknown) { + const input = typeof raw === "string" ? JSON.parse(raw) : raw; + const values = TeleworkAuthorizationValues.create(TeleworkAuthorizationInputSchema.parse(input)); + if (!values.isSuccess) { + throw new Error("Valores de teletrabajo persistidos inconsistentes."); + } + return values.data; +} +export class TeleworkAuthorizationPersistenceMapper { + toDomain(row: TeleworkRow, revisions: readonly TeleworkRevisionRow[]) { + const values = valuesOf(row.values); + const ordered = [...revisions].sort((a, b) => a.position - b.position); + const history = ordered.map((revision, position) => { + const revisionValues = valuesOf(revision.values); + if ( + revision.authorizationId !== row.id || + revision.position !== position || + !z.iso.datetime().safeParse(revision.recordedAt).success || + revision.recordedAt < (ordered[position - 1]?.recordedAt ?? "") || + !values.sameTarget(revisionValues) + ) { + throw new Error("Historial de teletrabajo persistido inconsistente."); + } + return TeleworkAuthorizationRevision.rehydrate( + { + values: revisionValues, + actorId: teleworkIdentity(revision.actorId), + recordedAt: revision.recordedAt, + }, + teleworkIdentity(revision.id) + ); + }); + if (!history.at(-1)?.values.equals(values) || row.personId !== values.personId.toPrimitive()) { + throw new Error("Referencias de teletrabajo persistidas inconsistentes."); + } + return TeleworkAuthorization.rehydrate( + { companyId: teleworkIdentity(row.companyId), values, history }, + teleworkIdentity(row.id) + ); + } + toPersistence(item: TeleworkAuthorization) { + return { + root: { + id: item.id.toPrimitive(), + companyId: item.companyId.toPrimitive(), + personId: item.values.personId.toPrimitive(), + values: JSON.stringify(item.values.toPrimitive()), + }, + revisions: item.history.map((revision, position) => ({ + id: revision.id.toPrimitive(), + authorizationId: item.id.toPrimitive(), + position, + actorId: revision.actorId.toPrimitive(), + recordedAt: revision.recordedAt, + values: JSON.stringify(revision.values.toPrimitive()), + })), + }; + } +} diff --git a/modules/companies/src/api/module.ts b/modules/companies/src/api/module.ts index 4cc383c..8f9f195 100644 --- a/modules/companies/src/api/module.ts +++ b/modules/companies/src/api/module.ts @@ -12,6 +12,7 @@ import type { IMailInventoryService } from "./application/mail-configuration.rep import { MailConfigurationUseCases } from "./application/mail-configuration.use-cases"; import { MailboxUseCases } from "./application/mailbox.use-cases"; import { PrinterConfigurationUseCases } from "./application/printer-configuration.use-cases"; +import { TeleworkAuthorizationUseCases } from "./application/telework-authorization.use-cases"; import type { IWorkProfileInventoryService } from "./application/work-profile.repository"; import { WorkProfileUseCases } from "./application/work-profile.use-cases"; import { registerAccessRoutes } from "./infrastructure/http/access.routes"; @@ -22,6 +23,7 @@ import { registerFolderMappingRoutes } from "./infrastructure/http/folder-mappin import { registerMailRoutes } from "./infrastructure/http/mail-configuration.routes"; import { registerMailboxRoutes } from "./infrastructure/http/mailbox.routes"; import { registerPrinterConfigurationRoutes } from "./infrastructure/http/printer-configuration.routes"; +import { registerTeleworkAuthorizationRoutes } from "./infrastructure/http/telework-authorization.routes"; import { registerWorkProfileRoutes } from "./infrastructure/http/work-profile.routes"; import { AccessPersistenceMapper, @@ -46,7 +48,9 @@ import { SequelizeFolderMappingRepository } from "./infrastructure/persistence/s import { SequelizeMailConfigurationRepository } from "./infrastructure/persistence/sequelize-mail-configuration.repository"; import { SequelizeMailboxRepository } from "./infrastructure/persistence/sequelize-mailbox.repository"; import { SequelizePrinterConfigurationRepository } from "./infrastructure/persistence/sequelize-printer-configuration.repository"; +import { SequelizeTeleworkAuthorizationRepository } from "./infrastructure/persistence/sequelize-telework-authorization.repository"; import { SequelizeWorkProfileRepository } from "./infrastructure/persistence/sequelize-work-profile.repository"; +import { migrateTeleworkAuthorizations } from "./infrastructure/persistence/telework-authorization-migration"; import { migrateWorkProfiles } from "./infrastructure/persistence/work-profile-migration"; export async function createCompanyModule( @@ -59,6 +63,10 @@ export async function createCompanyModule( initializeCompanyModels(database); initializePeopleModels(database); const repository = new SequelizeCompanyRepository(database, new CompanyPersistenceMapper()); + await migrateTeleworkAuthorizations(database); + const telework = new TeleworkAuthorizationUseCases( + new SequelizeTeleworkAuthorizationRepository(database) + ); await migrateAccess(database); const access = new AccessUseCases( new SequelizeAccessRepository(database, repository, new AccessPersistenceMapper()) @@ -117,6 +125,7 @@ export async function createCompanyModule( }, register: (app: Parameters[0]) => { registerCompanyRoutes(app, useCases, authenticate); + registerTeleworkAuthorizationRoutes(app, telework, authenticate); registerAccessRoutes(app, access, authenticate, legacyResourceWrites); }, }; diff --git a/modules/companies/src/common/index.ts b/modules/companies/src/common/index.ts index 73b6d4b..f52cb64 100644 --- a/modules/companies/src/common/index.ts +++ b/modules/companies/src/common/index.ts @@ -11,4 +11,5 @@ export * from "./folder-mappings"; export * from "./mail-configurations"; export * from "./mailboxes"; export * from "./printer-configurations"; +export * from "./telework-authorizations"; export * from "./work-profiles"; diff --git a/modules/companies/src/common/telework-authorizations.ts b/modules/companies/src/common/telework-authorizations.ts new file mode 100644 index 0000000..8c16dd1 --- /dev/null +++ b/modules/companies/src/common/telework-authorizations.ts @@ -0,0 +1,38 @@ +import { z } from "zod/v4"; + +import { TeleworkAuthorizationInputSchema } from "../api/domain/telework-authorization-values"; + +export type { TeleworkAuthorizationInput } from "../api/domain/telework-authorization-values"; + +export { TeleworkAuthorizationInputSchema }; +export const TeleworkParamsSchema = z.strictObject({ companyId: z.uuid() }); +export const TeleworkItemParamsSchema = TeleworkParamsSchema.extend({ id: z.uuid() }); +export const SaveTeleworkAuthorizationRequestSchema = z.strictObject({ + values: TeleworkAuthorizationInputSchema, +}); +export const TeleworkAuthorizationSchema = z.strictObject({ + id: z.uuid(), + companyId: z.uuid(), + values: TeleworkAuthorizationInputSchema, + status: z.enum(["authorized", "scheduled", "expired", "revoked"]), + effective: z.boolean(), + needsReview: z.boolean(), + history: z + .array( + z.strictObject({ + id: z.uuid(), + values: TeleworkAuthorizationInputSchema, + actorId: z.uuid(), + actorName: z.string().min(1), + recordedAt: z.iso.datetime(), + }) + ) + .min(1), +}); +export const TeleworkAuthorizationsResponseSchema = z.strictObject({ + authorizations: z.array(TeleworkAuthorizationSchema), +}); +export const TeleworkAuthorizationResponseSchema = z.strictObject({ + authorization: TeleworkAuthorizationSchema, +}); +export type TeleworkAuthorizationDTO = z.infer; diff --git a/modules/companies/src/web/components/company-detail.tsx b/modules/companies/src/web/components/company-detail.tsx index 42d8ab1..df045c3 100644 --- a/modules/companies/src/web/components/company-detail.tsx +++ b/modules/companies/src/web/components/company-detail.tsx @@ -28,6 +28,7 @@ import { MailConfigurationsPage } from "../mail/mail-configurations-page"; import { MailboxRelations } from "../mail/mailbox-relations"; import { PrinterConfigurationsPage } from "../printers/printer-configurations-page"; import { WorkProfilesPage } from "../profiles/work-profiles-page"; +import { TeleworkAuthorizationsPage } from "../telework/telework-authorizations-page"; import { CompanyFeedback } from "./company-feedback"; import { CompanyForm } from "./company-form"; @@ -110,6 +111,7 @@ function SiteSection({ company, csrfToken }: { company: CompanyDTO; csrfToken: s ); } type CompanyTab = + | "telework" | "people" | "departments" | "sites" @@ -216,6 +218,7 @@ export function CompanyDetail({ ["resources", resourceTranslation("inventory"), LayersIcon], ["workstations", assetTranslation("workstations"), BriefcaseIcon], ["profiles", t("profiles.title"), UsersIcon], + ["telework", t("telework.title"), KeyRoundIcon], ["people", `${t("people")} (${company.people.length})`, UsersIcon], [ "departments", @@ -265,6 +268,8 @@ function CompanySection({ tab: CompanyTab; }) { switch (tab) { + case "telework": + return ; case "people": return ; case "departments": diff --git a/modules/companies/src/web/i18n.ts b/modules/companies/src/web/i18n.ts index c4efab3..a9613e7 100644 --- a/modules/companies/src/web/i18n.ts +++ b/modules/companies/src/web/i18n.ts @@ -6,8 +6,10 @@ import { mailboxEnglish, mailboxSpanish } from "./mail/mailbox-i18n"; import { printersEnglish, printersSpanish } from "./printers/printer-i18n"; import { environmentEnglish, environmentSpanish } from "./profiles/environment-i18n"; import { profilesEnglish, profilesSpanish } from "./profiles/profile-i18n"; +import { teleworkEnglish, teleworkSpanish } from "./telework/telework-i18n"; export const companiesSpanish = { + telework: teleworkSpanish, environment: environmentSpanish, certificates: certificatesSpanish, applications: applicationsSpanish, @@ -130,6 +132,7 @@ export const companiesSpanish = { }, }; export const companiesEnglish = { + telework: teleworkEnglish, environment: environmentEnglish, certificates: certificatesEnglish, applications: applicationsEnglish, diff --git a/modules/companies/src/web/profiles/work-environment-page.tsx b/modules/companies/src/web/profiles/work-environment-page.tsx index ed1da67..bde73fd 100644 --- a/modules/companies/src/web/profiles/work-environment-page.tsx +++ b/modules/companies/src/web/profiles/work-environment-page.tsx @@ -11,6 +11,7 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import type { CompanyDTO, WorkProfileDTO } from "../../common"; +import type { TeleworkAuthorizationDTO } from "../../common/telework-authorizations"; import { accessApi, accessKeys } from "../access/access-api"; import { accessLabel } from "../access/access-labels"; import { applicationApi, applicationKeys } from "../applications/application-api"; @@ -18,6 +19,8 @@ import { certificateApi, certificateKeys } from "../certificates/certificate-api import { folderApi, folderKeys } from "../folders/folder-api"; import { mailApi, mailKeys } from "../mail/mail-api"; import { printerApi, printerKeys } from "../printers/printer-api"; +import { teleworkApi, teleworkKeys } from "../telework/telework-api"; +import { TeleworkAuthorizationSummary } from "../telework/telework-authorization-summary"; import { type EnvironmentRow, @@ -64,7 +67,12 @@ function useEnvironmentQueries(companyId: string) { queryKey: certificateKeys.company(companyId), queryFn: () => certificateApi.get(companyId), }); + const telework = useQuery({ + queryKey: teleworkKeys.company(companyId), + queryFn: () => teleworkApi.get(companyId), + }); const queries = [ + telework, profiles, assets, resources, @@ -80,6 +88,7 @@ function useEnvironmentQueries(companyId: string) { const retry = () => Promise.all(queries.map((query) => query.refetch())); if ( !( + telework.data && profiles.data && assets.data && resources.data && @@ -104,7 +113,16 @@ function useEnvironmentQueries(companyId: string) { applications: applications.data.configurations, certificates: certificates.data.configurations, }; - return { pending, error, retry, data: { profiles: profiles.data.profiles, sources } }; + return { + pending, + error, + retry, + data: { + profiles: profiles.data.profiles, + sources, + authorizations: telework.data.authorizations, + }, + }; } export function WorkEnvironmentPage({ company, @@ -114,7 +132,7 @@ export function WorkEnvironmentPage({ }: { company: CompanyDTO; profileId: string; - onManage: (kind: ProfileConfigurationKind) => void; + onManage: (kind: ProfileConfigurationKind | "telework") => void; onProfile: (id: string) => void; }) { const { t } = useTranslation("companies"); @@ -145,6 +163,7 @@ export function WorkEnvironmentPage({ } return ( void; + onManage: (kind: ProfileConfigurationKind | "telework") => void; onProfile: (id: string) => void; }) { const { t } = useTranslation("companies"); @@ -271,6 +292,22 @@ function EnvironmentContent({ )} + + +
+

{t("telework.title")}

+ +
+ + item.companyId === company.id && item.values.personId === profile.values.personId + )} + /> +
+
{(["configured", "pending", "removal", "review"] as const).map((key) => ( diff --git a/modules/companies/src/web/profiles/work-environment.test.tsx b/modules/companies/src/web/profiles/work-environment.test.tsx index 8d45392..c5f0dee 100644 --- a/modules/companies/src/web/profiles/work-environment.test.tsx +++ b/modules/companies/src/web/profiles/work-environment.test.tsx @@ -18,6 +18,7 @@ import { PrinterConfigurationSchema, WorkProfileSchema, } from "../../common"; +import { TeleworkAuthorizationSchema } from "../../common/telework-authorizations"; import { accessKeys } from "../access/access-api"; import { applicationKeys } from "../applications/application-api"; import { certificateKeys } from "../certificates/certificate-api"; @@ -25,6 +26,7 @@ import { folderKeys } from "../folders/folder-api"; import { companiesEnglish, companiesSpanish } from "../i18n"; import { mailKeys } from "../mail/mail-api"; import { printerKeys } from "../printers/printer-api"; +import { teleworkKeys } from "../telework/telework-api"; import { type EnvironmentSources, buildEnvironment } from "./work-environment.model"; import { WorkEnvironmentPage } from "./work-environment-page"; @@ -261,7 +263,7 @@ test("entorno: composición vigente del puesto y datos actuales sin duplicación }); async function render( language: string, - mode: "ready" | "pending" | "error" | "missing" | "archived" = "ready" + mode: "ready" | "pending" | "error" | "missing" | "archived" | "telework" = "ready" ) { const i18n = createInstance(); await i18n.init({ @@ -272,6 +274,42 @@ async function render( defaultOptions: { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY } }, }); if (mode !== "pending") { + client.setQueryData(teleworkKeys.company(company.id), { + authorizations: + mode === "telework" + ? [ + TeleworkAuthorizationSchema.parse({ + id: id(80), + companyId: company.id, + values: { + personId: profile.values.personId, + active: true, + startsOn: null, + expiresOn: null, + notes: "Teletrabajo de Ana", + }, + status: "authorized", + effective: true, + needsReview: false, + history: [ + { + id: id(81), + actorId, + actorName: "Técnico", + recordedAt, + values: { + personId: profile.values.personId, + active: true, + startsOn: null, + expiresOn: null, + notes: "Teletrabajo de Ana", + }, + }, + ], + }), + ] + : [], + }); client.setQueryData(profileKeys.company(company.id), { profiles: mode === "missing" @@ -346,3 +384,12 @@ test("entorno: carga, fallo parcial sin falsa ficha vacía, perfil inexistente y const archived = await render("es", "archived"); assert.ok(archived.includes(companiesSpanish.environment.archived)); }); + +test("entorno: consulta autorización por persona, común a sus perfiles y sin confundir preparación", async () => { + const html = await render("es", "telework"); + assert.ok(html.includes("Teletrabajo de Ana")); + assert.ok(html.includes(companiesSpanish.telework.effective)); + assert.ok(html.includes(companiesSpanish.telework.manage)); + const empty = await render("es"); + assert.ok(empty.includes(companiesSpanish.telework.notRecorded)); +}); diff --git a/modules/companies/src/web/profiles/work-profiles-page.tsx b/modules/companies/src/web/profiles/work-profiles-page.tsx index 085f098..f8098c8 100644 --- a/modules/companies/src/web/profiles/work-profiles-page.tsx +++ b/modules/companies/src/web/profiles/work-profiles-page.tsx @@ -15,6 +15,7 @@ import { CertificateConfigurationsPage } from "../certificates/certificate-confi import { FolderMappingsPage } from "../folders/folder-mappings-page"; import { MailConfigurationsPage } from "../mail/mail-configurations-page"; import { PrinterConfigurationsPage } from "../printers/printer-configurations-page"; +import { TeleworkAuthorizationsPage } from "../telework/telework-authorizations-page"; import { ProfileFeedback } from "./profile-feedback"; import type { ProfileConfigurationKind } from "./work-environment.model"; @@ -23,7 +24,7 @@ import { profileApi, profileKeys } from "./work-profile-api"; import { WorkProfileForm } from "./work-profile-form"; type ProfileSelection = { - kind: ProfileConfigurationKind | "environment"; + kind: ProfileConfigurationKind | "environment" | "telework"; profileId: string; fromEnvironment?: boolean; }; @@ -86,6 +87,7 @@ export function WorkProfilesPage({ ) } onSelection={setConfiguration} + personId={profiles.find((item) => item.id === configuration.profileId)?.values.personId} selection={configuration} /> ); @@ -291,6 +293,7 @@ export function WorkProfilesPage({ } function ProfileConfigurations({ + personId, company, csrfToken, selection, @@ -299,6 +302,7 @@ function ProfileConfigurations({ }: { company: CompanyDTO; csrfToken: string; + personId: string | undefined; selection: ProfileSelection; onSelection: (selection: ProfileSelection) => void; onBack: () => void; @@ -320,6 +324,13 @@ function ProfileConfigurations({ profileId={selection.profileId} /> )} + {selection.kind === "telework" && ( + + )} {selection.kind === "folders" && } {selection.kind === "mail" && } {selection.kind === "certificates" && } diff --git a/modules/companies/src/web/telework/telework-api.ts b/modules/companies/src/web/telework/telework-api.ts new file mode 100644 index 0000000..9645bb6 --- /dev/null +++ b/modules/companies/src/web/telework/telework-api.ts @@ -0,0 +1,43 @@ +import { z } from "zod/v4"; + +import { + type TeleworkAuthorizationInput, + TeleworkAuthorizationResponseSchema, + TeleworkAuthorizationsResponseSchema, +} from "../../common/telework-authorizations"; +export const teleworkKeys = { company: (id: string) => ["telework-authorizations", id] }; +export class TeleworkApiError extends Error { + constructor(public readonly code: string) { + super(code); + } +} +async function request( + companyId: string, + values?: TeleworkAuthorizationInput, + token?: string, + id?: string +) { + const response = await fetch( + `/api/telework-authorizations/${companyId}${id ? `/${id}/update` : ""}`, + { + method: values ? "POST" : "GET", + credentials: "same-origin", + headers: values ? { "content-type": "application/json", "x-csrf-token": token ?? "" } : {}, + ...(values ? { body: JSON.stringify({ values }) } : {}), + } + ); + const body: unknown = await response.json(); + if (!response.ok) { + const error = z.object({ code: z.string() }).safeParse(body); + throw new TeleworkApiError(error.success ? error.data.code : "INTERNAL_ERROR"); + } + return body; +} +export const teleworkApi = { + async get(companyId: string) { + return TeleworkAuthorizationsResponseSchema.parse(await request(companyId)); + }, + async save(companyId: string, values: TeleworkAuthorizationInput, token: string, id?: string) { + return TeleworkAuthorizationResponseSchema.parse(await request(companyId, values, token, id)); + }, +}; diff --git a/modules/companies/src/web/telework/telework-authorization-form.tsx b/modules/companies/src/web/telework/telework-authorization-form.tsx new file mode 100644 index 0000000..5854212 --- /dev/null +++ b/modules/companies/src/web/telework/telework-authorization-form.tsx @@ -0,0 +1,185 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button } from "@repo/shadcn-ui/components/button"; +import { Checkbox } from "@repo/shadcn-ui/components/checkbox"; +import { ContextHelp } from "@repo/shadcn-ui/components/context-help"; +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, 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 TeleworkAuthorizationDTO, + type TeleworkAuthorizationInput, + TeleworkAuthorizationInputSchema, +} from "../../common/telework-authorizations"; +import { PersonForm } from "../components/person-form"; + +import { teleworkApi, teleworkKeys } from "./telework-api"; +import { TeleworkFeedback } from "./telework-feedback"; + +export function TeleworkAuthorizationForm({ + company, + csrfToken, + authorization, + authorizations, + initialPersonId, + onSaved, +}: { + company: CompanyDTO; + csrfToken: string; + authorization?: TeleworkAuthorizationDTO | undefined; + authorizations: readonly TeleworkAuthorizationDTO[]; + initialPersonId?: string | undefined; + 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 [quick, setQuick] = useState(false); + const form = useForm({ + resolver: zodResolver(TeleworkAuthorizationInputSchema), + defaultValues: authorization?.values ?? { + personId: initialPersonId ?? "", + active: true, + startsOn: null, + expiresOn: null, + notes: null, + }, + }); + const values = form.watch(); + const fixed = Boolean(authorization || initialPersonId); + const mutation = useMutation({ + mutationFn: (input: TeleworkAuthorizationInput) => + teleworkApi.save(company.id, input, csrfToken, authorization?.id), + onSuccess: async () => { + await client.invalidateQueries({ queryKey: teleworkKeys.company(company.id) }); + if (mounted.current) { + onSaved(); + } + }, + }); + const reserved = new Set(authorizations.map((item) => item.values.personId)); + return ( + <> +
mutation.mutate(input))}> +
+
+ + setQuick(true) }} + disabled={fixed} + id={`${prefix}-person`} + label={t("telework.person")} + onValueChange={(value) => form.setValue("personId", value)} + options={company.people + .filter( + (person) => + person.id === values.personId || (person.active && !reserved.has(person.id)) + ) + .map((person) => ({ value: person.id, label: person.name }))} + placeholder={t("mail.choose")} + required + value={values.personId} + /> +
+
+ form.setValue("active", checked === true)} + /> + +
+
+
+ + form.setValue("startsOn", event.currentTarget.value || null)} + type="date" + value={values.startsOn ?? ""} + /> +
+
+ + form.setValue("expiresOn", event.currentTarget.value || null)} + type="date" + value={values.expiresOn ?? ""} + /> +
+
+
+ +