From 3ceac47cd69cafb051466c620c77b930d4681259 Mon Sep 17 00:00:00 2001 From: David Date: Mon, 12 Oct 2026 20:31:21 +0200 Subject: [PATCH] feat: implement software installation management - Add SoftwareInstallationValues value object for encapsulating software installation data. - Create HTTP routes for managing software installations, including GET, POST, and DELETE operations. - Implement Sequelize repository for software installations, handling database interactions. - Add migration script for creating necessary database tables for software installations. - Create persistence mapper for transforming between domain models and database rows. - Define snapshot schema for software installations to ensure data consistency. - Introduce common schemas for software installations to validate API requests and responses. - Develop UI components for displaying and managing software installations, including history dialogs and forms. - Implement API client for interacting with software installation endpoints. - Write tests for software installations panel and form components to ensure functionality. --- apps/server/src/identity.test.ts | 7 + apps/server/src/resources.integration.ts | 4 +- .../src/software-installations.integration.ts | 112 ++++++++ docs/aplicaciones-del-perfil.md | 2 +- docs/puestos-de-trabajo.md | 3 + docs/software-del-ordenador.md | 31 +++ modules/companies/package.json | 2 +- .../application-configuration.repository.ts | 2 +- .../certificate-configuration.repository.ts | 2 +- .../software-installation-input.mapper.ts | 50 ++++ .../software-installation-policy.ts | 25 ++ .../software-installation-response.builder.ts | 27 ++ .../software-installation.repository.ts | 30 +++ .../software-installation.use-cases.ts | 72 +++++ .../software-installation-revision.entity.ts | 32 +++ .../domain/software-installation-values.ts | 27 ++ .../domain/software-installation.aggregate.ts | 102 +++++++ .../api/domain/software-installation.test.ts | 133 ++++++++++ .../software-installation-values.vo.ts | 74 ++++++ .../http/software-installation.routes.ts | 117 +++++++++ ...uelize-software-installation.repository.ts | 141 ++++++++++ .../software-installation-migration.ts | 49 ++++ ...oftware-installation-persistence.mapper.ts | 130 +++++++++ .../software-installation-snapshot.ts | 27 ++ modules/companies/src/api/module.ts | 20 +- modules/companies/src/common/index.ts | 22 +- modules/companies/src/common/locales/en.json | 31 ++- modules/companies/src/common/locales/es.json | 31 ++- .../src/common/software-installations.ts | 45 ++++ .../history/ui/software-history-dialog.tsx | 49 ++++ .../list/ui/software-installations-panel.tsx | 248 ++++++++++++++++++ .../shared/api/software-api.ts | 46 ++++ .../shared/software.test.tsx | 113 ++++++++ .../shared/ui/software-installation-form.tsx | 170 ++++++++++++ .../detail/ui/workstation-detail-page.tsx | 6 + .../application/resource-inventory.service.ts | 4 +- .../src/api/domain/resource-catalog-seed.ts | 3 +- .../src/api/domain/resource-catalog.test.ts | 16 +- .../src/api/domain/resource-definition.ts | 2 +- modules/resources/src/common/locales/en.json | 5 +- modules/resources/src/common/locales/es.json | 5 +- .../helpers/catalog-template-form-values.ts | 4 + .../shared/ui/catalog-template-form.tsx | 17 +- .../shared/helpers/resource-form-values.ts | 2 +- .../shared/types/resource-editor-props.ts | 2 +- .../shared/types/resource-form-props.ts | 2 +- 46 files changed, 1993 insertions(+), 51 deletions(-) create mode 100644 apps/server/src/software-installations.integration.ts create mode 100644 docs/software-del-ordenador.md create mode 100644 modules/companies/src/api/application/software-installation-input.mapper.ts create mode 100644 modules/companies/src/api/application/software-installation-policy.ts create mode 100644 modules/companies/src/api/application/software-installation-response.builder.ts create mode 100644 modules/companies/src/api/application/software-installation.repository.ts create mode 100644 modules/companies/src/api/application/software-installation.use-cases.ts create mode 100644 modules/companies/src/api/domain/software-installation-revision.entity.ts create mode 100644 modules/companies/src/api/domain/software-installation-values.ts create mode 100644 modules/companies/src/api/domain/software-installation.aggregate.ts create mode 100644 modules/companies/src/api/domain/software-installation.test.ts create mode 100644 modules/companies/src/api/domain/value-objects/software-installation-values.vo.ts create mode 100644 modules/companies/src/api/infrastructure/http/software-installation.routes.ts create mode 100644 modules/companies/src/api/infrastructure/persistence/sequelize-software-installation.repository.ts create mode 100644 modules/companies/src/api/infrastructure/persistence/software-installation-migration.ts create mode 100644 modules/companies/src/api/infrastructure/persistence/software-installation-persistence.mapper.ts create mode 100644 modules/companies/src/api/infrastructure/persistence/software-installation-snapshot.ts create mode 100644 modules/companies/src/common/software-installations.ts create mode 100644 modules/companies/src/web/software-installations/history/ui/software-history-dialog.tsx create mode 100644 modules/companies/src/web/software-installations/list/ui/software-installations-panel.tsx create mode 100644 modules/companies/src/web/software-installations/shared/api/software-api.ts create mode 100644 modules/companies/src/web/software-installations/shared/software.test.tsx create mode 100644 modules/companies/src/web/software-installations/shared/ui/software-installation-form.tsx diff --git a/apps/server/src/identity.test.ts b/apps/server/src/identity.test.ts index f36c8c4..a1e4cd4 100644 --- a/apps/server/src/identity.test.ts +++ b/apps/server/src/identity.test.ts @@ -50,6 +50,7 @@ import { verifyRelationships } from "./relationships.integration"; import { verifyRemoteConnections } from "./remote.integration"; import { verifyResourceMigration } from "./resource-migration.integration"; import { verifyResources } from "./resources.integration"; +import { verifySoftwareInstallations } from "./software-installations.integration"; import { verifySupport } from "./support.integration"; import { verifyTelework } from "./telework.integration"; import { verifyTickets } from "./tickets.integration"; @@ -888,6 +889,12 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de secondCompany.id, database ); + await verifySoftwareInstallations( + (...args) => operator.request(...args), + customer.id, + secondCompany.id, + database + ); await verifyApplications( (...args) => operator.request(...args), customer.id, diff --git a/apps/server/src/resources.integration.ts b/apps/server/src/resources.integration.ts index 4e9421e..82e69a5 100644 --- a/apps/server/src/resources.integration.ts +++ b/apps/server/src/resources.integration.ts @@ -58,8 +58,8 @@ export async function verifyResources( const initial = await read(global); - assert.equal(initial.items.length, 27); - assert.equal(initial.items.filter((item) => item.values.kind === "template").length, 7); + assert.equal(initial.items.length, 28); + assert.equal(initial.items.filter((item) => item.values.kind === "template").length, 8); assert.ok(initial.items.every((item) => item.builtin && item.id[14] === "7")); assert.ok( initial.items.every((item) => diff --git a/apps/server/src/software-installations.integration.ts b/apps/server/src/software-installations.integration.ts new file mode 100644 index 0000000..1b86a87 --- /dev/null +++ b/apps/server/src/software-installations.integration.ts @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; + +import { InventoryResponseSchema } from "@metis/assets/common"; +import { + SoftwareInstallationResponseSchema, + SoftwareInstallationsResponseSchema, +} from "@metis/companies/common"; +import { CatalogResponseSchema, ResourceResponseSchema } from "@metis/resources/common"; +import type { Sequelize } from "sequelize"; + +import { migrateSoftwareInstallations } from "../../../modules/companies/src/api/infrastructure/persistence/software-installation-migration"; + +type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise; + +export async function verifySoftwareInstallations( + request: Request, + companyId: string, + otherId: string, + database: Sequelize +) { + await migrateSoftwareInstallations(database); + const inventory = InventoryResponseSchema.parse( + await (await request(`/assets/${companyId}`)).json() + ); + const host = inventory.assets.find((asset) => asset.values.status !== "retired"); + const otherHost = InventoryResponseSchema.parse( + await (await request(`/assets/${otherId}`)).json() + ).assets[0]; + const catalog = CatalogResponseSchema.parse( + await (await request(`/resource-catalog/${companyId}`)).json() + ); + const template = catalog.items.find((item) => item.builtinKey === "template:os"); + const application = catalog.items.find((item) => item.builtinKey === "template:application"); + const certificate = catalog.items.find((item) => item.builtinKey === "template:certificate"); + + assert.ok(host && otherHost && template && application && certificate); + assert.ok(template.values.kind === "template" && template.values.usage === "os"); + + const addResource = async (templateId: string, name: string) => { + const response = await request(`/resources/${companyId}`, { + values: { name, templateId, active: true, notes: "", characteristics: {} }, + }); + + assert.equal(response.status, 201, await response.clone().text()); + + return ResourceResponseSchema.parse(await response.json()).resource; + }; + + const os = await addResource(template.id, "Windows 11 Pro test"); + const app = await addResource(application.id, "Office equipo test"); + const cert = await addResource(certificate.id, "Certificado no instalable como software"); + const path = `/software-installations/${companyId}`; + const values = { + assetId: host.id, + resourceId: os.id, + version: "24H2", + state: "configured", + notes: null, + }; + const foreign = await request(path, { values: { ...values, assetId: otherHost.id } }); + + assert.equal(foreign.status, 404); + assert.equal((await request(path, { values: { ...values, resourceId: cert.id } })).status, 404); + assert.equal((await request(path, { values: { ...values, profileId: host.id } })).status, 400); + assert.equal((await request(path, { values }, "https://other.example")).status, 403); + assert.equal((await request(path, { values }, undefined, "a".repeat(64))).status, 403); + const responses = await Promise.all([request(path, { values }), request(path, { values })]); + + assert.deepEqual(responses.map((response) => response.status).sort(), [201, 409]); + const success = responses.find((response) => response.status === 201); + + assert.ok(success); + const created = SoftwareInstallationResponseSchema.parse(await success.json()).configuration; + + const save = async (data: typeof values) => { + const response = await request(`${path}/${created.id}/update`, { values: data }); + + assert.equal(response.status, 200, await response.clone().text()); + + return SoftwareInstallationResponseSchema.parse(await response.json()).configuration; + }; + + assert.deepEqual(await save(values), created); + const updated = await save({ ...values, version: "25H2" }); + + assert.equal(updated.history.length, 2); + assert.equal(updated.history[0]?.values.version, "24H2"); + assert.equal(updated.history[1]?.values.version, "25H2"); + assert.equal((await request(path, { values: { ...values, resourceId: app.id } })).status, 201); + assert.equal( + (await request(`/software-installations/${otherId}/${created.id}/update`, { values })).status, + 404 + ); + assert.equal( + SoftwareInstallationsResponseSchema.parse( + await (await request(`/software-installations/${otherId}`)).json() + ).configurations.length, + 0 + ); + const deactivate = await request(`/resources/${companyId}/${os.id}/update`, { + values: { ...os.values, active: false }, + }); + + assert.equal(deactivate.status, 200); + const listed = SoftwareInstallationsResponseSchema.parse(await (await request(path)).json()); + + assert.equal(listed.configurations.find((item) => item.id === created.id)?.needsReview, true); + await save({ ...values, version: "25H2", state: "removal_pending" }); + await save({ ...values, version: "25H2", state: "removed" }); + assert.equal((await request(`${path}/${created.id}/delete`, {})).status, 200); + assert.equal((await request(`${path}/${created.id}/delete`, {})).status, 404); +} diff --git a/docs/aplicaciones-del-perfil.md b/docs/aplicaciones-del-perfil.md index ca69d30..322bf62 100644 --- a/docs/aplicaciones-del-perfil.md +++ b/docs/aplicaciones-del-perfil.md @@ -14,7 +14,7 @@ Pendiente de configurar documenta una necesidad. Configurado documenta que se ha Forma de uso distingue aplicación instalada en el equipo y acceso web. Versión es opcional y solo se admite en una aplicación instalada. Cambiar a acceso web vacía la versión explícitamente en el formulario. Notas es opcional, hasta 4.000 caracteres: por ejemplo, Thunderbird solicitado por Ana. -Proveedor, URL y descripción pertenecen al recurso común. Forma de uso, versión y estado pertenecen al perfil. Registrar una aplicación como instalada en un perfil no afirma que se haya instalado exclusivamente para ese usuario ni crea otra instalación por cada perfil. Tampoco instala software, concede permisos, almacena credenciales o consume licencias. +Proveedor, URL y descripción pertenecen al recurso común. Forma de uso, versión y estado pertenecen al perfil. Las [instalaciones del ordenador](software-del-ordenador.md) documentan por separado el software para el equipo y su sistema operativo actual; no se duplican por cada perfil. Registrar una aplicación como instalada en un perfil no afirma que se haya instalado exclusivamente para ese usuario ni crea otra instalación por cada perfil. Tampoco instala software, concede permisos, almacena credenciales o consume licencias. ## Estados e historial diff --git a/docs/puestos-de-trabajo.md b/docs/puestos-de-trabajo.md index 8a1cc20..25b2cc2 100644 --- a/docs/puestos-de-trabajo.md +++ b/docs/puestos-de-trabajo.md @@ -44,3 +44,6 @@ La relación opcional `personId` identifica al responsable del puesto, no a todo La ficha reúne únicamente perfiles del ordenador principal, incluidos los archivados cuando corresponda. Los periféricos y activos compartidos no aportan perfiles. El entorno actual y la consulta del perfil utilizan el mismo criterio. Cambiar el ordenador principal no reasigna perfiles automáticamente: los perfiles vigentes que dejan de tener un puesto activo requieren revisión. + + +La ficha consulta también el [software del ordenador](software-del-ordenador.md), incluido el sistema operativo actual, por `computerAssetId`. Las instalaciones permanecen vinculadas al activo al cambiar el ordenador principal. diff --git a/docs/software-del-ordenador.md b/docs/software-del-ordenador.md new file mode 100644 index 0000000..8c94639 --- /dev/null +++ b/docs/software-del-ordenador.md @@ -0,0 +1,31 @@ +# Software del ordenador + +## Uso + +La ficha de un puesto muestra **Software del ordenador**, asociado a su ordenador principal. Permite registrar el sistema operativo actual y las aplicaciones instaladas para el equipo, sin exigir persona ni perfil. Dos perfiles del mismo equipo consultan la misma instalación; cambiar el responsable no cambia el software. Al sustituir el ordenador principal, la ficha consulta las instalaciones del equipo nuevo y conserva las del anterior. + +El sistema operativo es un recurso de la plantilla global **Sistema operativo**, con proveedor, edición y descripción; las copias y plantillas propias admiten uso explícito `os`. Las aplicaciones usan el recurso de uso `application`. La versión/build concreta pertenece a la instalación; el producto y edición pertenecen al recurso. Se aceptan varias instalaciones de sistemas operativos distintos (por ejemplo, arranque dual); no se deduce su tipo por el nombre ni se guarda el SO actual en las características del activo. El SO de fábrica sigue describiendo la compra original. + +El alta y la edición utilizan modal. Hay altas rápidas para sistema operativo y aplicación reutilizando el editor de recursos sin un tercer nivel de formularios. Cancelar el hijo conserva el padre; un recurso ya guardado permanece aunque se cancele la instalación. El listado reutiliza EntityListHeader, EntityTable, acciones por fila y paginación compartida, con búsqueda, columnas, ordenación, inclusión de cerradas y limpiar filtros. Recurso enlaza a su ficha. + +## Relación con perfiles + +La instalación del ordenador y la disponibilidad de una aplicación para una persona son relaciones distintas. El bloque de aplicaciones del perfil conserva sus registros, forma de uso y versiones; puede representar aplicaciones web o instalaciones propias de ese usuario. No se generan instalaciones de equipo a partir de registros antiguos porque no documentan ese ámbito. Tampoco se crean aplicaciones en todos los perfiles por registrar software en el equipo. No se instalan programas ni se asignan o consumen licencias automáticamente. + +## Reglas + +La pareja ordenador/recurso es fija y solo admite una instalación abierta. Una cerrada permite un nuevo registro. Se comparte el ciclo ya existente: pendiente → configurado o nunca configurado; configurado → pendiente de retirar → retirado. Los estados terminales no se editan. Las notas pueden completarse mientras se revisa o retira una instalación. + +El servidor comprueba pertenencia a compañía, tipo de recurso, disponibilidad del activo y actividad del recurso. Las altas o reconfiguraciones pendientes/configuradas exigen referencias disponibles; retirar y completar notas sigue siendo posible si el activo está retirado o el recurso inactivo. Se conserva historial y aparece un aviso de revisión. Eliminar corrige un alta por error y elimina también sus revisiones, con confirmación explícita en la interfaz. + +## Persistencia y API + +SoftwareInstallation es un agregado, con SoftwareInstallationValues y revisiones con UUID propio. `software_installations` y `software_installation_revisions` guardan columnas tradicionales: compañía, activo, recurso, versión, estado, notas, actor y fecha. Claves foráneas protegen referencias, una clave nullable única protege la pareja abierta y el índice de revisiones protege su orden. No se persiste JSON. + +Migración explícita e idempotente al inicializar el módulo. Las escrituras se serializan con el bloqueo de compañía y guardan raíz/revisiones dentro de la misma transacción. Una actualización semánticamente equivalente no añade revisión. El mapper comprueba identidad, pareja fija, fechas, transiciones, última revisión y clave abierta. + +`GET /api/software-installations/:companyId` consulta todas las instalaciones de la compañía; la ficha filtra por ordenador. `POST` crea; `POST /:companyId/:id/update` edita; `POST /:companyId/:id/delete` elimina. Sesión obligatoria, validación estricta, origen y CSRF en escrituras; actor tomado de la sesión. La auditoría central continúa como entrega independiente. + +## Verificación + +Pruebas de dominio y persistencia: referencias, SO/aplicaciones, duplicados, ámbito, identidad fija, idempotencia, versiones, revisión, transiciones e historial corrupto. Pruebas HTTP con MariaDB: migración repetida, concurrencia, CSRF/origen, compañía ajena, versiones históricas, recursos inactivos, retirada y eliminación. Pruebas de interfaz es/en: filtrado por ordenador, enlaces, versiones, ausencia de formularios incrustados y ayuda. diff --git a/modules/companies/package.json b/modules/companies/package.json index ccfdccd..d4c9fb8 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/companies/shared/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/shared/access.test.tsx src/web/mail-configurations/shared/mail.test.tsx src/api/domain/work-profile.test.ts src/web/work-profiles/shared/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printer-configurations/shared/printer.test.tsx src/api/domain/application-configuration.test.ts src/web/application-configurations/shared/application.test.tsx src/api/domain/certificate-configuration.test.ts src/web/certificate-configurations/shared/certificate.test.tsx src/web/work-environments/shared/work-environment.test.tsx src/api/domain/telework-authorization.test.ts src/web/telework-authorizations/shared/telework.test.tsx src/api/domain/remote-connection.test.ts src/api/domain/process.test.ts src/web/remote-connections/shared/remote.test.tsx src/web/processes/shared/processes.test.tsx src/api/domain/ticket.test.ts src/web/tickets/shared/tickets.test.tsx src/api/domain/intervention-template.test.ts src/web/intervention-templates/shared/templates.test.tsx src/api/domain/intervention.test.ts src/web/interventions/shared/interventions.test.tsx src/web/shared/initials-avatar.test.tsx src/api/infrastructure/company-logo-processor.test.ts src/web/companies/shared/company-avatar.test.tsx src/web/shared/client-contracts.test.ts src/web/workstations/detail/helpers/workstation-relations.test.ts src/web/workstations/detail/ui/workstation-detail.test.tsx src/web/workspace/shared/workspace-routes.test.ts src/web/workspace/view/helpers/sidebar-preference.test.ts" + "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/companies/shared/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/shared/access.test.tsx src/web/mail-configurations/shared/mail.test.tsx src/api/domain/work-profile.test.ts src/web/work-profiles/shared/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printer-configurations/shared/printer.test.tsx src/api/domain/application-configuration.test.ts src/web/application-configurations/shared/application.test.tsx src/api/domain/certificate-configuration.test.ts src/web/certificate-configurations/shared/certificate.test.tsx src/web/work-environments/shared/work-environment.test.tsx src/api/domain/telework-authorization.test.ts src/web/telework-authorizations/shared/telework.test.tsx src/api/domain/remote-connection.test.ts src/api/domain/process.test.ts src/web/remote-connections/shared/remote.test.tsx src/web/processes/shared/processes.test.tsx src/api/domain/ticket.test.ts src/web/tickets/shared/tickets.test.tsx src/api/domain/intervention-template.test.ts src/web/intervention-templates/shared/templates.test.tsx src/api/domain/intervention.test.ts src/web/interventions/shared/interventions.test.tsx src/web/shared/initials-avatar.test.tsx src/api/infrastructure/company-logo-processor.test.ts src/web/companies/shared/company-avatar.test.tsx src/web/shared/client-contracts.test.ts src/web/workstations/detail/helpers/workstation-relations.test.ts src/web/workstations/detail/ui/workstation-detail.test.tsx src/web/workspace/shared/workspace-routes.test.ts src/web/workspace/view/helpers/sidebar-preference.test.ts src/api/domain/software-installation.test.ts src/web/software-installations/shared/software.test.tsx" }, "dependencies": { "@metis/core": "workspace:*", diff --git a/modules/companies/src/api/application/application-configuration.repository.ts b/modules/companies/src/api/application/application-configuration.repository.ts index 58dc7d3..50c1c98 100644 --- a/modules/companies/src/api/application/application-configuration.repository.ts +++ b/modules/companies/src/api/application/application-configuration.repository.ts @@ -13,7 +13,7 @@ export type ApplicationContext = Readonly<{ resources: readonly { id: UniqueID; active: boolean; - usage: "application" | "certificate" | "support" | null; + usage: "application" | "certificate" | "support" | "os" | null; }[]; actors: ReadonlyMap; }>; diff --git a/modules/companies/src/api/application/certificate-configuration.repository.ts b/modules/companies/src/api/application/certificate-configuration.repository.ts index 1774f67..7f064bf 100644 --- a/modules/companies/src/api/application/certificate-configuration.repository.ts +++ b/modules/companies/src/api/application/certificate-configuration.repository.ts @@ -14,7 +14,7 @@ export type CertificateContext = Readonly<{ resources: readonly { id: UniqueID; active: boolean; - usage: "application" | "certificate" | "support" | null; + usage: "application" | "certificate" | "support" | "os" | null; expiresOn: string | null; }[]; actors: ReadonlyMap; diff --git a/modules/companies/src/api/application/software-installation-input.mapper.ts b/modules/companies/src/api/application/software-installation-input.mapper.ts new file mode 100644 index 0000000..505d1d2 --- /dev/null +++ b/modules/companies/src/api/application/software-installation-input.mapper.ts @@ -0,0 +1,50 @@ +import { Result } from "@repo/rdx-utils"; + +import type { SoftwareInstallation } from "../domain/software-installation.aggregate"; +import type { SoftwareInstallationInput } from "../domain/software-installation-values"; +import { SoftwareInstallationError } from "../domain/software-installation-values"; +import { SoftwareInstallationValues } from "../domain/value-objects/software-installation-values.vo"; + +import type { SoftwareContext } from "./software-installation.repository"; +import { softwareAvailable, softwareReferences } from "./software-installation-policy"; + +export function mapSoftwareInput( + input: SoftwareInstallationInput, + context: SoftwareContext, + previous?: SoftwareInstallation +) { + const parsed = SoftwareInstallationValues.create(input); + + if (!parsed.isSuccess) { + return parsed; + } + const values = parsed.data; + + if (previous && !previous.values.sameTarget(values)) { + return Result.fail(new SoftwareInstallationError("SOFTWARE_TARGET_IMMUTABLE")); + } + if (!softwareReferences(values, context)) { + return Result.fail(new SoftwareInstallationError("SOFTWARE_REFERENCE_NOT_FOUND")); + } + const reconfiguring = + !previous || + previous.values.state !== values.state || + previous.values.version.getOrUndefined() !== values.version.getOrUndefined(); + + if ( + ["pending", "configured"].includes(values.state) && + reconfiguring && + !softwareAvailable(values, context) + ) { + return Result.fail(new SoftwareInstallationError("SOFTWARE_REFERENCE_UNAVAILABLE")); + } + const others = context.configurations.filter( + (item) => !(item.id.equals(previous?.id) || item.values.closed) + ); + + if (!values.closed && others.some((item) => item.values.sameTarget(values))) { + return Result.fail(new SoftwareInstallationError("SOFTWARE_ALREADY_OPEN")); + } + + return parsed; +} diff --git a/modules/companies/src/api/application/software-installation-policy.ts b/modules/companies/src/api/application/software-installation-policy.ts new file mode 100644 index 0000000..fb9495f --- /dev/null +++ b/modules/companies/src/api/application/software-installation-policy.ts @@ -0,0 +1,25 @@ +import type { SoftwareInstallationValues } from "../domain/value-objects/software-installation-values.vo"; + +import type { SoftwareContext } from "./software-installation.repository"; + +export function softwareReferences(values: SoftwareInstallationValues, context: SoftwareContext) { + return ( + context.assets.some((item) => item.id.equals(values.assetId)) && + context.resources.some( + (item) => + item.id.equals(values.resourceId) && (item.usage === "application" || item.usage === "os") + ) + ); +} + +export function softwareAvailable(values: SoftwareInstallationValues, context: SoftwareContext) { + return ( + context.assets.some((item) => item.id.equals(values.assetId) && item.available) && + context.resources.some( + (item) => + item.id.equals(values.resourceId) && + item.active && + (item.usage === "application" || item.usage === "os") + ) + ); +} diff --git a/modules/companies/src/api/application/software-installation-response.builder.ts b/modules/companies/src/api/application/software-installation-response.builder.ts new file mode 100644 index 0000000..f870108 --- /dev/null +++ b/modules/companies/src/api/application/software-installation-response.builder.ts @@ -0,0 +1,27 @@ +import type { SoftwareInstallationDTO } from "../../common/software-installations"; +import type { SoftwareInstallation } from "../domain/software-installation.aggregate"; + +import type { SoftwareContext } from "./software-installation.repository"; +import { softwareAvailable } from "./software-installation-policy"; + +export function buildSoftwareResponse( + item: SoftwareInstallation, + context: SoftwareContext +): SoftwareInstallationDTO { + return { + id: item.id.toPrimitive(), + companyId: item.companyId.toPrimitive(), + values: item.values.toPrimitive(), + needsReview: + !item.values.closed && + (!softwareAvailable(item.values, context) || item.values.state === "removal_pending"), + history: item.history.map((revision) => ({ + id: revision.id.toPrimitive(), + actorId: revision.actorId.toPrimitive(), + actorName: + context.actors.get(revision.actorId.toPrimitive()) ?? revision.actorId.toPrimitive(), + recordedAt: revision.recordedAt, + values: revision.values.toPrimitive(), + })), + }; +} diff --git a/modules/companies/src/api/application/software-installation.repository.ts b/modules/companies/src/api/application/software-installation.repository.ts new file mode 100644 index 0000000..3d15ac3 --- /dev/null +++ b/modules/companies/src/api/application/software-installation.repository.ts @@ -0,0 +1,30 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import type { Result } from "@repo/rdx-utils"; + +import type { SoftwareInstallation } from "../domain/software-installation.aggregate"; +import type { SoftwareInstallationError } from "../domain/software-installation-values"; + +export type SoftwareContext = Readonly<{ + configurations: readonly SoftwareInstallation[]; + assets: readonly { id: UniqueID; available: boolean }[]; + resources: readonly { + id: UniqueID; + active: boolean; + usage: "application" | "certificate" | "support" | "os" | null; + }[]; + actors: ReadonlyMap; +}>; + +export interface ISoftwareInstallationRepository { + remove(company: UniqueID, id: UniqueID): Promise>; + get(company: UniqueID): Promise>; + save( + company: UniqueID, + action: (context: SoftwareContext) => Result + ): Promise< + Result< + { configuration: SoftwareInstallation; context: SoftwareContext }, + SoftwareInstallationError + > + >; +} diff --git a/modules/companies/src/api/application/software-installation.use-cases.ts b/modules/companies/src/api/application/software-installation.use-cases.ts new file mode 100644 index 0000000..b769956 --- /dev/null +++ b/modules/companies/src/api/application/software-installation.use-cases.ts @@ -0,0 +1,72 @@ +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { SoftwareInstallation } from "../domain/software-installation.aggregate"; +import type { SoftwareInstallationInput } from "../domain/software-installation-values"; +import { SoftwareInstallationError } from "../domain/software-installation-values"; + +import type { ISoftwareInstallationRepository } from "./software-installation.repository"; +import { mapSoftwareInput } from "./software-installation-input.mapper"; + +export class SoftwareInstallationUseCases { + constructor(private readonly repository: ISoftwareInstallationRepository) {} + + remove(companyId: string, id: string) { + const company = UniqueID.create(companyId); + const identity = UniqueID.create(id); + + if (!(company.isSuccess && identity.isSuccess)) { + return Promise.resolve(Result.fail(new SoftwareInstallationError("INVALID_REQUEST"))); + } + + return this.repository.remove(company.data, identity.data); + } + + get(companyId: string) { + const company = UniqueID.create(companyId); + + return company.isSuccess + ? this.repository.get(company.data) + : Promise.resolve(Result.fail(new SoftwareInstallationError("INVALID_REQUEST"))); + } + + save( + companyId: string, + id: string | undefined, + input: SoftwareInstallationInput, + 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 SoftwareInstallationError("INVALID_REQUEST"))); + } + + return this.repository.save(company.data, (context) => { + const previous = identity?.isSuccess + ? context.configurations.find((item) => item.id.equals(identity.data)) + : undefined; + + if (id && !previous) { + return Result.fail(new SoftwareInstallationError("SOFTWARE_CONFIGURATION_NOT_FOUND")); + } + const values = mapSoftwareInput(input, context, previous); + + if (!values.isSuccess) { + return values; + } + const now = new Date().toISOString(); + const candidate = previous + ? previous.update(values.data, actor.data, now) + : SoftwareInstallation.create(company.data, values.data, actor.data, now); + + if (!candidate.isSuccess) { + return candidate; + } + + return candidate; + }); + } +} diff --git a/modules/companies/src/api/domain/software-installation-revision.entity.ts b/modules/companies/src/api/domain/software-installation-revision.entity.ts new file mode 100644 index 0000000..8d490e7 --- /dev/null +++ b/modules/companies/src/api/domain/software-installation-revision.entity.ts @@ -0,0 +1,32 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import { DomainEntity } from "@repo/rdx-ddd"; + +import type { SoftwareInstallationValues } from "./value-objects/software-installation-values.vo"; + +export type SoftwareInstallationRevisionProps = Readonly<{ + values: SoftwareInstallationValues; + actorId: UniqueID; + recordedAt: string; +}>; + +export class SoftwareInstallationRevision extends DomainEntity { + static create(props: SoftwareInstallationRevisionProps) { + return new SoftwareInstallationRevision(Object.freeze({ ...props })); + } + + static rehydrate(props: SoftwareInstallationRevisionProps, id: UniqueID) { + return new SoftwareInstallationRevision(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/software-installation-values.ts b/modules/companies/src/api/domain/software-installation-values.ts new file mode 100644 index 0000000..291be74 --- /dev/null +++ b/modules/companies/src/api/domain/software-installation-values.ts @@ -0,0 +1,27 @@ +import { z } from "zod/v4"; + +import { ConfigurationSchema } from "./access/access-definition"; + +export const SoftwareInstallationInputSchema = z.strictObject({ + assetId: z.uuid(), + resourceId: z.uuid(), + version: z.string().trim().max(100).nullable(), + state: ConfigurationSchema, + notes: z.string().trim().max(4000).nullable(), +}); + +export type SoftwareInstallationInput = z.infer; + +export class SoftwareInstallationError extends Error { + constructor(public readonly code: string) { + super(code); + } +} + +export const SOFTWARE_STATE_TRANSITIONS = { + pending: ["pending", "configured", "never_configured"], + configured: ["configured", "removal_pending"], + removal_pending: ["removal_pending", "removed"], + removed: ["removed"], + never_configured: ["never_configured"], +} as const; diff --git a/modules/companies/src/api/domain/software-installation.aggregate.ts b/modules/companies/src/api/domain/software-installation.aggregate.ts new file mode 100644 index 0000000..ac01db4 --- /dev/null +++ b/modules/companies/src/api/domain/software-installation.aggregate.ts @@ -0,0 +1,102 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import { AggregateRoot } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import { z } from "zod/v4"; + +import { SoftwareInstallationRevision } from "./software-installation-revision.entity"; +import { + SOFTWARE_STATE_TRANSITIONS, + SoftwareInstallationError, +} from "./software-installation-values"; +import type { SoftwareInstallationValues } from "./value-objects/software-installation-values.vo"; + +type Props = Readonly<{ + companyId: UniqueID; + values: SoftwareInstallationValues; + history: readonly SoftwareInstallationRevision[]; +}>; + +export class SoftwareInstallation extends AggregateRoot { + private constructor(props: Props, id?: UniqueID) { + super(Object.freeze({ ...props, history: Object.freeze([...props.history]) }), id); + } + + static create( + companyId: UniqueID, + values: SoftwareInstallationValues, + actorId: UniqueID, + recordedAt: string + ): Result { + if ( + !( + z.iso.datetime().safeParse(recordedAt).success && + ["pending", "configured"].includes(values.state) + ) + ) { + return Result.fail(new SoftwareInstallationError("INVALID_SOFTWARE_TRANSITION")); + } + + return Result.ok( + new SoftwareInstallation({ + companyId, + values, + history: [SoftwareInstallationRevision.create({ values, actorId, recordedAt })], + }) + ); + } + + static rehydrate(props: Props, id: UniqueID) { + return new SoftwareInstallation(props, id); + } + + get companyId() { + return this.props.companyId; + } + + get values() { + return this.props.values; + } + + get history() { + return this.props.history; + } + + update( + values: SoftwareInstallationValues, + actorId: UniqueID, + recordedAt: string + ): Result { + if (this.values.equals(values)) { + return Result.ok(this); + } + if (!this.values.sameTarget(values)) { + return Result.fail(new SoftwareInstallationError("SOFTWARE_TARGET_IMMUTABLE")); + } + if ( + !SOFTWARE_STATE_TRANSITIONS[this.values.state].some((state) => state === values.state) || + (this.values.closed && !this.values.equals(values)) + ) { + return Result.fail(new SoftwareInstallationError("INVALID_SOFTWARE_TRANSITION")); + } + if ( + !z.iso.datetime().safeParse(recordedAt).success || + recordedAt < (this.history.at(-1)?.recordedAt ?? "") + ) { + return Result.fail(new SoftwareInstallationError("INVALID_REQUEST")); + } + + return Result.ok( + new SoftwareInstallation( + { + ...this.props, + values, + history: [ + ...this.history, + SoftwareInstallationRevision.create({ values, actorId, recordedAt }), + ], + }, + this.id + ) + ); + } +} diff --git a/modules/companies/src/api/domain/software-installation.test.ts b/modules/companies/src/api/domain/software-installation.test.ts new file mode 100644 index 0000000..f256943 --- /dev/null +++ b/modules/companies/src/api/domain/software-installation.test.ts @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { UniqueID } from "@repo/rdx-ddd"; + +import type { SoftwareContext } from "../application/software-installation.repository"; +import { mapSoftwareInput } from "../application/software-installation-input.mapper"; +import { buildSoftwareResponse } from "../application/software-installation-response.builder"; +import { SoftwareInstallationPersistenceMapper } from "../infrastructure/persistence/software-installation-persistence.mapper"; + +import { SoftwareInstallation } from "./software-installation.aggregate"; +import type { SoftwareInstallationInput } from "./software-installation-values"; +import { SoftwareInstallationInputSchema } from "./software-installation-values"; +import { SoftwareInstallationValues } from "./value-objects/software-installation-values.vo"; + +const company = UniqueID.generateNewID(); +const asset = UniqueID.generateNewID(); +const resource = UniqueID.generateNewID(); +const actor = UniqueID.generateNewID(); +const date = "2026-10-12T10:00:00.000Z"; +const input: SoftwareInstallationInput = { + assetId: asset.toPrimitive(), + resourceId: resource.toPrimitive(), + version: "24H2", + state: "pending", + notes: null, +}; +const context: SoftwareContext = { + configurations: [], + assets: [{ id: asset, available: true }], + resources: [{ id: resource, active: true, usage: "os" }], + actors: new Map([[actor.toPrimitive(), "David"]]), +}; + +function values(data = input) { + const result = SoftwareInstallationValues.create(data); + + assert.ok(result.isSuccess); + + return result.data; +} + +function installation() { + const result = SoftwareInstallation.create(company, values(), actor, date); + + assert.ok(result.isSuccess); + + return result.data; +} + +test("instalaciones: pertenecen al equipo, admiten SO/aplicaciones y rechazan recursos ajenos y destinos variables", () => { + assert.ok(mapSoftwareInput(input, context).isSuccess); + assert.ok( + mapSoftwareInput(input, { + ...context, + resources: [{ id: resource, active: true, usage: "application" }], + }).isSuccess + ); + assert.ok(!mapSoftwareInput(input, { ...context, assets: [] }).isSuccess); + assert.ok( + !mapSoftwareInput(input, { + ...context, + resources: [{ id: resource, active: true, usage: "certificate" }], + }).isSuccess + ); + assert.equal( + SoftwareInstallationInputSchema.safeParse({ ...input, profileId: actor.toPrimitive() }).success, + false + ); + const previous = installation(); + + assert.ok(!mapSoftwareInput(input, { ...context, configurations: [previous] }).isSuccess); + assert.ok( + mapSoftwareInput(input, { ...context, configurations: [previous] }, previous).isSuccess + ); + assert.ok( + !previous.update(values({ ...input, assetId: actor.toPrimitive() }), actor, date).isSuccess + ); +}); + +test("instalaciones: versiones, idempotencia, retirada, revisión y persistencia de historial", () => { + const previous = installation(); + + assert.ok(previous.id.toPrimitive().charAt(14) === "7"); + const same = previous.update(values(), actor, date); + + assert.ok(same.isSuccess); + assert.equal(same.data, previous); + const configured = previous.update( + values({ ...input, state: "configured", version: "25H2" }), + actor, + date + ); + + assert.ok(configured.isSuccess); + const unavailable = { ...context, assets: [{ id: asset, available: false }] }; + + assert.ok(buildSoftwareResponse(configured.data, unavailable).needsReview); + assert.ok( + mapSoftwareInput( + { ...configured.data.values.toPrimitive(), notes: "Revisar equipo" }, + unavailable, + configured.data + ).isSuccess + ); + assert.ok(!mapSoftwareInput(input, unavailable).isSuccess); + assert.ok(!configured.data.update(values({ ...input, state: "removed" }), actor, date).isSuccess); + const retiring = configured.data.update( + values({ ...input, state: "removal_pending" }), + actor, + date + ); + + assert.ok(retiring.isSuccess); + const removed = retiring.data.update(values({ ...input, state: "removed" }), actor, date); + + assert.ok(removed.isSuccess); + assert.ok(!removed.data.update(values(), actor, date).isSuccess); + assert.ok(mapSoftwareInput(input, { ...context, configurations: [removed.data] }).isSuccess); + const mapper = new SoftwareInstallationPersistenceMapper(); + const rows = mapper.toPersistence(removed.data); + const restored = mapper.toDomain(rows.root, rows.revisions); + + assert.equal(restored.history.length, 4); + assert.ok(restored.values.equals(removed.data.values)); + assert.throws(() => mapper.toDomain({ ...rows.root, currentKey: "incorrect" }, rows.revisions)); + assert.throws(() => + mapper.toDomain( + rows.root, + rows.revisions.map((row) => ({ ...row, position: 9 })) + ) + ); +}); diff --git a/modules/companies/src/api/domain/value-objects/software-installation-values.vo.ts b/modules/companies/src/api/domain/value-objects/software-installation-values.vo.ts new file mode 100644 index 0000000..3743f0b --- /dev/null +++ b/modules/companies/src/api/domain/value-objects/software-installation-values.vo.ts @@ -0,0 +1,74 @@ +import { UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import type { SoftwareInstallationInput } from "../software-installation-values"; +import { + SoftwareInstallationError, + SoftwareInstallationInputSchema, +} from "../software-installation-values"; + +export class SoftwareInstallationValues { + private constructor( + public readonly assetId: UniqueID, + public readonly resourceId: UniqueID, + public readonly version: Maybe, + public readonly state: SoftwareInstallationInput["state"], + public readonly notes: Maybe + ) { + Object.freeze(this); + } + + static create( + input: SoftwareInstallationInput + ): Result { + const parsed = SoftwareInstallationInputSchema.safeParse(input); + + if (!parsed.success) { + return Result.fail(new SoftwareInstallationError("INVALID_REQUEST")); + } + const data = parsed.data; + const asset = UniqueID.create(data.assetId); + const resource = UniqueID.create(data.resourceId); + + if (!(asset.isSuccess && resource.isSuccess)) { + return Result.fail(new SoftwareInstallationError("INVALID_REQUEST")); + } + + return Result.ok( + new SoftwareInstallationValues( + asset.data, + resource.data, + data.version ? Maybe.some(data.version) : Maybe.none(), + data.state, + data.notes ? Maybe.some(data.notes) : Maybe.none() + ) + ); + } + + get closed() { + return this.state === "removed" || this.state === "never_configured"; + } + + sameTarget(other: SoftwareInstallationValues) { + return this.assetId.equals(other.assetId) && this.resourceId.equals(other.resourceId); + } + + equals(other: SoftwareInstallationValues) { + return ( + this.sameTarget(other) && + this.state === other.state && + this.version.getOrUndefined() === other.version.getOrUndefined() && + this.notes.getOrUndefined() === other.notes.getOrUndefined() + ); + } + + toPrimitive(): SoftwareInstallationInput { + return { + assetId: this.assetId.toPrimitive(), + resourceId: this.resourceId.toPrimitive(), + version: this.version.getOrUndefined() ?? null, + state: this.state, + notes: this.notes.getOrUndefined() ?? null, + }; + } +} diff --git a/modules/companies/src/api/infrastructure/http/software-installation.routes.ts b/modules/companies/src/api/infrastructure/http/software-installation.routes.ts new file mode 100644 index 0000000..5fbb544 --- /dev/null +++ b/modules/companies/src/api/infrastructure/http/software-installation.routes.ts @@ -0,0 +1,117 @@ +import type { Application, RequestHandler, Response } from "express"; +import { Router } from "express"; +import { z } from "zod/v4"; + +import { + SaveSoftwareInstallationRequestSchema, + SoftwareItemParamsSchema, + SoftwareParamsSchema, +} from "../../../common/software-installations"; +import type { SoftwareInstallationUseCases } from "../../application/software-installation.use-cases"; +import { buildSoftwareResponse } from "../../application/software-installation-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 registerSoftwareInstallationRoutes( + app: Application, + useCases: SoftwareInstallationUseCases, + authenticate: RequestHandler +) { + const router = Router(); + + router.get("/:companyId", (request, response, next) => { + const params = SoftwareParamsSchema.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({ + configurations: result.data.configurations.map((item) => + buildSoftwareResponse(item, result.data) + ), + }); + }) + .catch(next); + }); + for (const path of ["/:companyId", "/:companyId/:id/update"]) { + router.post(path, (request, response, next) => { + const params = ( + path.endsWith("update") ? SoftwareItemParamsSchema : SoftwareParamsSchema + ).safeParse(request.params); + const body = SaveSoftwareInstallationRequestSchema.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({ + configuration: buildSoftwareResponse(result.data.configuration, result.data.context), + }); + }) + .catch(next); + }); + } + router.post("/:companyId/:id/delete", (request, response, next) => { + const params = SoftwareItemParamsSchema.safeParse(request.params); + + if ( + !( + params.success && + z.strictObject({}).safeParse(request.body).success && + z.strictObject({}).safeParse(request.query).success + ) + ) { + fail(response, "INVALID_REQUEST"); + + return; + } + useCases + .remove(params.data.companyId, params.data.id) + .then((result) => { + if (!result.isSuccess) { + fail(response, result.error.code); + + return; + } + response.json({ deleted: true }); + }) + .catch(next); + }); + app.use("/api/software-installations", authenticate, router); +} diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize-software-installation.repository.ts b/modules/companies/src/api/infrastructure/persistence/sequelize-software-installation.repository.ts new file mode 100644 index 0000000..3e96aa5 --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/sequelize-software-installation.repository.ts @@ -0,0 +1,141 @@ +import type { IResourceInventoryService } from "@metis/resources/services"; +import type { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import type { Sequelize, Transaction } from "sequelize"; +import { QueryTypes } from "sequelize"; + +import type { + ISoftwareInstallationRepository, + SoftwareContext, +} from "../../application/software-installation.repository"; +import { softwareReferences } from "../../application/software-installation-policy"; +import type { IWorkProfileInventoryService } from "../../application/work-profile.repository"; +import { SoftwareInstallationError } from "../../domain/software-installation-values"; + +import type { SoftwareRevisionRow, SoftwareRow } from "./software-installation-persistence.mapper"; +import { SoftwareInstallationPersistenceMapper } from "./software-installation-persistence.mapper"; + +export class SequelizeSoftwareInstallationRepository implements ISoftwareInstallationRepository { + private readonly mapper = new SoftwareInstallationPersistenceMapper(); + + constructor( + private readonly database: Sequelize, + private readonly inventory: IWorkProfileInventoryService, + private readonly resources: IResourceInventoryService + ) {} + + private async load( + company: UniqueID, + transaction: Transaction, + write: boolean + ): Promise> { + const replacements = { companyId: company.toPrimitive() }; + const options = { replacements, transaction, type: QueryTypes.SELECT }; + const companies = await this.database.query<{ id: string }>( + `SELECT id FROM companies WHERE id=:companyId${write ? " FOR UPDATE" : ""}`, + { ...options, type: QueryTypes.SELECT } + ); + + if (!companies.length) { + return Result.fail(new SoftwareInstallationError("COMPANY_NOT_FOUND")); + } + const rows = await this.database.query( + "SELECT * FROM software_installations WHERE companyId=:companyId", + { ...options, type: QueryTypes.SELECT } + ); + const revisions = await this.database.query( + "SELECT r.* FROM software_installation_revisions r JOIN software_installations m ON m.id=r.configurationId WHERE m.companyId=:companyId", + { ...options, type: QueryTypes.SELECT } + ); + const actors = await this.database.query<{ id: string; name: string }>( + "SELECT id,name FROM users", + { transaction, type: QueryTypes.SELECT } + ); + const context: SoftwareContext = { + configurations: rows.map((row) => + this.mapper.toDomain( + row, + revisions.filter((r) => r.configurationId === row.id) + ) + ), + assets: await this.inventory.list(company), + resources: await this.resources.list(company), + actors: new Map(actors.map((actor) => [actor.id, actor.name])), + }; + + if (context.configurations.some((mapping) => !softwareReferences(mapping.values, context))) { + throw new Error("Ámbito de instalación persistido inconsistente."); + } + + return Result.ok(context); + } + + get(company: UniqueID) { + return this.database.transaction((t) => this.load(company, t, false)); + } + + remove(company: UniqueID, id: UniqueID): Promise> { + return this.database.transaction(async (transaction) => { + const replacements = { companyId: company.toPrimitive(), id: id.toPrimitive() }; + + await this.database.query("SELECT id FROM companies WHERE id=:companyId FOR UPDATE", { + replacements, + transaction, + type: QueryTypes.SELECT, + }); + const rows = await this.database.query<{ id: string }>( + "SELECT id FROM software_installations WHERE companyId=:companyId AND id=:id FOR UPDATE", + { replacements, transaction, type: QueryTypes.SELECT } + ); + + if (!rows.length) { + return Result.fail(new SoftwareInstallationError("SOFTWARE_CONFIGURATION_NOT_FOUND")); + } + await this.database.query( + "DELETE FROM software_installation_revisions WHERE configurationId=:id", + { replacements, transaction } + ); + await this.database.query( + "DELETE FROM software_installations WHERE companyId=:companyId AND id=:id", + { replacements, transaction } + ); + + return Result.ok(undefined); + }); + } + + save(company: UniqueID, action: Parameters[1]) { + return this.database.transaction(async (transaction) => { + const loaded = await this.load(company, transaction, true); + + if (!loaded.isSuccess) { + return loaded; + } + const candidate = action(loaded.data); + + if (!candidate.isSuccess) { + return candidate; + } + const previous = loaded.data.configurations.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 software_installations (id,companyId,assetId,resourceId,version,state,notes,currentKey) VALUES (:id,:companyId,:assetId,:resourceId,:version,:state,:notes,:currentKey) ON DUPLICATE KEY UPDATE version=VALUES(version),state=VALUES(state),notes=VALUES(notes),currentKey=VALUES(currentKey)", + { replacements: record.root, transaction } + ); + const stored = new Set(previous?.history.map((r) => r.id.toPrimitive())); + + for (const revision of record.revisions.filter((r) => !stored.has(r.id))) { + await this.database.query( + "INSERT INTO software_installation_revisions (id,configurationId,position,actorId,recordedAt,assetId,resourceId,version,state,notes) VALUES (:id,:configurationId,:position,:actorId,:recordedAt,:assetId,:resourceId,:version,:state,:notes)", + { replacements: revision, transaction } + ); + } + } + + return Result.ok({ configuration: candidate.data, context: loaded.data }); + }); + } +} diff --git a/modules/companies/src/api/infrastructure/persistence/software-installation-migration.ts b/modules/companies/src/api/infrastructure/persistence/software-installation-migration.ts new file mode 100644 index 0000000..388c7b2 --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/software-installation-migration.ts @@ -0,0 +1,49 @@ +import type { Sequelize } from "sequelize"; +import { DataTypes, QueryTypes } from "sequelize"; + +import { softwareSnapshot } from "./software-installation-snapshot"; + +export async function migrateSoftwareInstallations(database: Sequelize) { + const q = 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) => ({ + type: DataTypes.UUID, + allowNull: false, + references: { model: table, key: "id" }, + onDelete: "RESTRICT", + }); + + if (!tables.some((t) => t.name === "software_installations")) { + await q.createTable("software_installations", { + id: { type: DataTypes.UUID, primaryKey: true }, + companyId: fk("companies"), + ...softwareSnapshot.columns, + currentKey: { type: DataTypes.STRING(80), allowNull: true, unique: true }, + }); + } + if (!tables.some((t) => t.name === "software_installation_revisions")) { + await q.createTable("software_installation_revisions", { + id: { type: DataTypes.UUID, primaryKey: true }, + configurationId: fk("software_installations"), + position: { type: DataTypes.INTEGER, allowNull: false }, + ...softwareSnapshot.columns, + actorId: fk("users"), + recordedAt: { type: DataTypes.STRING(24), 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='software_installation_revisions'", + { type: QueryTypes.SELECT } + ); + + if (!indexes.some((item) => item.name === "software_installation_position")) { + await q.addIndex("software_installation_revisions", ["configurationId", "position"], { + unique: true, + name: "software_installation_position", + }); + } +} diff --git a/modules/companies/src/api/infrastructure/persistence/software-installation-persistence.mapper.ts b/modules/companies/src/api/infrastructure/persistence/software-installation-persistence.mapper.ts new file mode 100644 index 0000000..5f5861d --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/software-installation-persistence.mapper.ts @@ -0,0 +1,130 @@ +import { z } from "zod/v4"; + +import { SoftwareInstallation } from "../../domain/software-installation.aggregate"; +import { SoftwareInstallationRevision } from "../../domain/software-installation-revision.entity"; +import { + SOFTWARE_STATE_TRANSITIONS, + SoftwareInstallationInputSchema, +} from "../../domain/software-installation-values"; +import { SoftwareInstallationValues } from "../../domain/value-objects/software-installation-values.vo"; + +import { snapshotValues } from "./scalar-snapshot-reader"; +import { softwareSnapshot } from "./software-installation-snapshot"; +import { profileIdentity } from "./work-profile-persistence.mapper"; + +export type SoftwareRow = { + id: string; + companyId: string; + assetId: string; + resourceId: string; + currentKey: string | null; +}; + +export type SoftwareRevisionRow = { + id: string; + configurationId: string; + position: number; + actorId: string; + recordedAt: string; +}; + +function mapped(raw: unknown) { + const parsed = SoftwareInstallationValues.create(SoftwareInstallationInputSchema.parse(raw)); + + if (!parsed.isSuccess) { + throw new Error("Valores de instalación persistidos inconsistentes."); + } + + return parsed.data; +} + +export class SoftwareInstallationPersistenceMapper { + toDomain(row: SoftwareRow, revisions: readonly SoftwareRevisionRow[]) { + const values = mapped(snapshotValues(softwareSnapshot, row)); + const ordered = [...revisions].sort((a, b) => a.position - b.position); + const history = ordered.map((revision, index) => { + const value = mapped(snapshotValues(softwareSnapshot, revision)); + const previous = ordered[index - 1]; + + if ( + revision.configurationId !== row.id || + revision.position !== index || + !z.iso.datetime().safeParse(revision.recordedAt).success || + revision.recordedAt < (previous?.recordedAt ?? "") || + !values.sameTarget(value) + ) { + throw new Error("Historial de instalación persistido inconsistente."); + } + + return SoftwareInstallationRevision.rehydrate( + { + values: value, + actorId: profileIdentity(revision.actorId), + recordedAt: revision.recordedAt, + }, + profileIdentity(revision.id) + ); + }); + + if (!(history.length && ["pending", "configured"].includes(history[0]?.values.state ?? ""))) { + throw new Error("Estado inicial de instalación persistido inconsistente."); + } + for (const [index, revision] of history.entries()) { + const previous = history[index - 1]?.values; + + if ( + previous && + (!SOFTWARE_STATE_TRANSITIONS[previous.state].some( + (state) => state === revision.values.state + ) || + (previous.closed && !previous.equals(revision.values))) + ) { + throw new Error("Estados de instalación históricos inconsistentes."); + } + } + const keys = softwareKeys(values); + + if ( + !history.at(-1)?.values.equals(values) || + row.assetId !== values.assetId.toPrimitive() || + row.resourceId !== values.resourceId.toPrimitive() || + row.currentKey !== keys.currentKey + ) { + throw new Error("Referencias de instalación persistidas inconsistentes."); + } + + return SoftwareInstallation.rehydrate( + { companyId: profileIdentity(row.companyId), values, history }, + profileIdentity(row.id) + ); + } + + toPersistence(item: SoftwareInstallation) { + const values = item.values.toPrimitive(); + + return { + root: { + id: item.id.toPrimitive(), + companyId: item.companyId.toPrimitive(), + ...values, + ...softwareKeys(item.values), + }, + revisions: item.history.map((revision, position) => ({ + id: revision.id.toPrimitive(), + configurationId: item.id.toPrimitive(), + position, + actorId: revision.actorId.toPrimitive(), + recordedAt: revision.recordedAt, + ...revision.values.toPrimitive(), + })), + }; + } +} + +function softwareKeys(values: SoftwareInstallationValues) { + return { + currentKey: values.closed + ? null + : `${values.assetId.toPrimitive()}:${values.resourceId.toPrimitive()}`, + }; +} diff --git a/modules/companies/src/api/infrastructure/persistence/software-installation-snapshot.ts b/modules/companies/src/api/infrastructure/persistence/software-installation-snapshot.ts new file mode 100644 index 0000000..1ef6f56 --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/software-installation-snapshot.ts @@ -0,0 +1,27 @@ +import { DataTypes } from "sequelize"; + +import { SoftwareInstallationInputSchema } from "../../domain/software-installation-values"; + +import type { SnapshotDefinition } from "./scalar-snapshot-reader"; + +export const softwareSnapshot = { + schema: SoftwareInstallationInputSchema, + columns: { + assetId: { + type: DataTypes.UUID, + allowNull: false, + references: { model: "assets", key: "id" }, + onDelete: "RESTRICT", + }, + resourceId: { + type: DataTypes.UUID, + allowNull: false, + references: { model: "resources", key: "id" }, + onDelete: "RESTRICT", + }, + version: { type: DataTypes.STRING(100), allowNull: true }, + state: { type: DataTypes.STRING(20), allowNull: false }, + notes: { type: DataTypes.TEXT, allowNull: true }, + }, + booleans: [], +} satisfies SnapshotDefinition>; diff --git a/modules/companies/src/api/module.ts b/modules/companies/src/api/module.ts index 18313d6..b715899 100644 --- a/modules/companies/src/api/module.ts +++ b/modules/companies/src/api/module.ts @@ -19,32 +19,34 @@ import { InterventionTemplateUseCases } from "./application/intervention-templat import type { IMailInventoryService } from "./application/mail-configuration.repository"; import { MailConfigurationUseCases } from "./application/mail-configuration.use-cases"; import { MailboxUseCases } from "./application/mailbox.use-cases"; +import { PersonPhotoUseCases } from "./application/person-photo.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 { SoftwareInstallationUseCases } from "./application/software-installation.use-cases"; import { TeleworkAuthorizationUseCases } from "./application/telework-authorization.use-cases"; import type { ITicketInventoryService } from "./application/ticket.repository"; import { TicketUseCases } from "./application/ticket.use-cases"; import type { IWorkProfileInventoryService } from "./application/work-profile.repository"; import { WorkProfileUseCases } from "./application/work-profile.use-cases"; import { CompanyLogoProcessor } from "./infrastructure/company-logo-processor"; -import { PersonPhotoProcessor } from "./infrastructure/person-photo-processor"; import { registerAccessRoutes } from "./infrastructure/http/access.routes"; import { registerApplicationConfigurationRoutes } from "./infrastructure/http/application-configuration.routes"; import { registerCertificateConfigurationRoutes } from "./infrastructure/http/certificate-configuration.routes"; import { registerCompanyRoutes } from "./infrastructure/http/company.routes"; import { registerCompanyLogoRoutes } from "./infrastructure/http/company-logo.routes"; -import { registerPersonPhotoRoutes } from "./infrastructure/http/person-photo.routes"; import { registerDashboardRoutes } from "./infrastructure/http/dashboard.routes"; import { registerFolderMappingRoutes } from "./infrastructure/http/folder-mapping.routes"; import { registerInterventionRoutes } from "./infrastructure/http/intervention.routes"; import { registerInterventionTemplateRoutes } from "./infrastructure/http/intervention-template.routes"; import { registerMailRoutes } from "./infrastructure/http/mail-configuration.routes"; import { registerMailboxRoutes } from "./infrastructure/http/mailbox.routes"; +import { registerPersonPhotoRoutes } from "./infrastructure/http/person-photo.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 { registerSoftwareInstallationRoutes } from "./infrastructure/http/software-installation.routes"; import { registerTeleworkAuthorizationRoutes } from "./infrastructure/http/telework-authorization.routes"; import { registerTicketRoutes } from "./infrastructure/http/ticket.routes"; import { registerWorkProfileRoutes } from "./infrastructure/http/work-profile.routes"; @@ -66,6 +68,7 @@ import { migrateMailboxes } from "./infrastructure/persistence/mailbox-migration import { migrateCompanies } from "./infrastructure/persistence/migrate-companies"; import { migratePeople } from "./infrastructure/persistence/migrate-people"; import { initializePeopleModels } from "./infrastructure/persistence/people.model"; +import { migratePersonPhotos } from "./infrastructure/persistence/person-photo-migration"; import { migratePrinterConfigurations } from "./infrastructure/persistence/printer-configuration-migration"; import { migrateProcesses } from "./infrastructure/persistence/process-migration"; import { migrateRemoteConnections } from "./infrastructure/persistence/remote-connection-migration"; @@ -73,24 +76,25 @@ import { SequelizeApplicationConfigurationRepository } from "./infrastructure/pe import { SequelizeCertificateConfigurationRepository } from "./infrastructure/persistence/sequelize-certificate-configuration.repository"; import { SequelizeCompanyRepository } from "./infrastructure/persistence/sequelize-company.repository"; import { SequelizeCompanyLogoRepository } from "./infrastructure/persistence/sequelize-company-logo.repository"; -import { SequelizePersonPhotoRepository } from "./infrastructure/persistence/sequelize-person-photo.repository"; -import { migratePersonPhotos } from "./infrastructure/persistence/person-photo-migration"; -import { PersonPhotoUseCases } from "./application/person-photo.use-cases"; import { SequelizeDashboardRepository } from "./infrastructure/persistence/sequelize-dashboard.repository"; import { SequelizeFolderMappingRepository } from "./infrastructure/persistence/sequelize-folder-mapping.repository"; import { SequelizeInterventionRepository } from "./infrastructure/persistence/sequelize-intervention.repository"; import { SequelizeInterventionTemplateRepository } from "./infrastructure/persistence/sequelize-intervention-template.repository"; import { SequelizeMailConfigurationRepository } from "./infrastructure/persistence/sequelize-mail-configuration.repository"; import { SequelizeMailboxRepository } from "./infrastructure/persistence/sequelize-mailbox.repository"; +import { SequelizePersonPhotoRepository } from "./infrastructure/persistence/sequelize-person-photo.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 { SequelizeSoftwareInstallationRepository } from "./infrastructure/persistence/sequelize-software-installation.repository"; import { SequelizeTeleworkAuthorizationRepository } from "./infrastructure/persistence/sequelize-telework-authorization.repository"; import { SequelizeTicketRepository } from "./infrastructure/persistence/sequelize-ticket.repository"; import { SequelizeWorkProfileRepository } from "./infrastructure/persistence/sequelize-work-profile.repository"; +import { migrateSoftwareInstallations } from "./infrastructure/persistence/software-installation-migration"; import { migrateTeleworkAuthorizations } from "./infrastructure/persistence/telework-authorization-migration"; import { migrateTickets } from "./infrastructure/persistence/ticket-migration"; import { migrateWorkProfiles } from "./infrastructure/persistence/work-profile-migration"; +import { PersonPhotoProcessor } from "./infrastructure/person-photo-processor"; export async function createCompanyModule( database: Sequelize, @@ -172,6 +176,11 @@ export async function createCompanyModule( await migrateFolderMappings(database); await migratePrinterConfigurations(database); await migrateApplicationConfigurations(database); + await migrateSoftwareInstallations(database); + const software = new SoftwareInstallationUseCases( + new SequelizeSoftwareInstallationRepository(database, inventory, resources) + ); + await migrateCertificateConfigurations(database); const certificates = new CertificateConfigurationUseCases( new SequelizeCertificateConfigurationRepository(database, inventory, resources) @@ -199,6 +208,7 @@ export async function createCompanyModule( registerFolderMappingRoutes(app, folders, authenticate); registerPrinterConfigurationRoutes(app, printers, authenticate); registerApplicationConfigurationRoutes(app, applications, authenticate); + registerSoftwareInstallationRoutes(app, software, authenticate); registerCertificateConfigurationRoutes(app, certificates, authenticate); }, }; diff --git a/modules/companies/src/common/index.ts b/modules/companies/src/common/index.ts index a9dbc2d..df2887f 100644 --- a/modules/companies/src/common/index.ts +++ b/modules/companies/src/common/index.ts @@ -1,48 +1,28 @@ export * from "./application-configurations"; - export * from "./certificate-configurations"; - export { COMPANY_LOGO_LIMITS, COMPANY_LOGO_TYPES, CompanyLogoResponseSchema, CompanyLogoVersionSchema, } from "./company-logo"; - export * from "./dashboard"; - export * from "./dto/request/access.request.dto"; - export * from "./dto/request/company.request.dto"; - export * from "./dto/request/people.request.dto"; - export * from "./dto/response/company.response.dto"; - export * from "./dto/shared/access.dto"; - export * from "./dto/shared/company.dto"; - export * from "./dto/shared/people.dto"; - export * from "./folder-mappings"; - export * from "./intervention-templates"; - export * from "./interventions"; - export * from "./mail-configurations"; - export * from "./mailboxes"; - export * from "./printer-configurations"; - export * from "./processes"; - export * from "./remote-connections"; - +export * from "./software-installations"; export * from "./telework-authorizations"; - export * from "./tickets"; - export * from "./work-profiles"; diff --git a/modules/companies/src/common/locales/en.json b/modules/companies/src/common/locales/en.json index c38c2c1..757e6fc 100644 --- a/modules/companies/src/common/locales/en.json +++ b/modules/companies/src/common/locales/en.json @@ -938,7 +938,36 @@ "usedWorkstations": "Workstations they use", "noResponsibleWorkstations": "This person is not responsible for any active workstation.", "noUsedWorkstations": "This person has no active profiles on the main computers of active workstations.", - "workstationProfilesLoadError": "Profiles could not be loaded. Workstation usage cannot be determined." + "workstationProfilesLoadError": "Profiles could not be loaded. Workstation usage cannot be determined.", + "software": { + "title": "Computer software", + "new": "Add installation", + "edit": "Edit installation", + "resource": "Software", + "type": "Type", + "os": "Operating system", + "application": "Application", + "description": "Record the current operating system and software installed on this computer.", + "help": "The installation belongs to the computer. Profile applications describe each person’s requirements and availability. The factory operating system remains on the asset. Programs are not installed and licenses are not consumed automatically.", + "noComputer": "Designate the workstation main computer first.", + "empty": "No installations recorded.", + "review": "Needs review", + "loadError": "Unable to load installations and resources.", + "errors": { + "INVALID_REQUEST": "Check the installation details.", + "COMPANY_NOT_FOUND": "Company not found.", + "SOFTWARE_CONFIGURATION_NOT_FOUND": "Installation not found.", + "SOFTWARE_REFERENCE_NOT_FOUND": "The computer or resource does not belong to this company, or the resource is not software.", + "SOFTWARE_REFERENCE_UNAVAILABLE": "The computer is retired or the resource is inactive.", + "SOFTWARE_ALREADY_OPEN": "An open installation of this software already exists on this computer.", + "SOFTWARE_TARGET_IMMUTABLE": "The computer and resource cannot change.", + "INVALID_SOFTWARE_TRANSITION": "This state transition is not allowed.", + "INTERNAL_ERROR": "Unable to save the installation." + }, + "examples": "Examples: Windows 11 Pro 24H2, Thunderbird 140 or Microsoft 365 Apps.", + "showClosed": "Include removed and never configured installations", + "newOs": "New operating system" + } }, "access": { "helpTitle": "What is it and what is it for?", diff --git a/modules/companies/src/common/locales/es.json b/modules/companies/src/common/locales/es.json index 9e99406..c7648b5 100644 --- a/modules/companies/src/common/locales/es.json +++ b/modules/companies/src/common/locales/es.json @@ -938,7 +938,36 @@ "usedWorkstations": "Puestos que utiliza", "noResponsibleWorkstations": "No es responsable de ningún puesto activo.", "noUsedWorkstations": "No tiene perfiles vigentes en los ordenadores principales de puestos activos.", - "workstationProfilesLoadError": "No se pudieron consultar los perfiles. No se puede determinar qué puestos utiliza." + "workstationProfilesLoadError": "No se pudieron consultar los perfiles. No se puede determinar qué puestos utiliza.", + "software": { + "title": "Software del ordenador", + "new": "Añadir instalación", + "edit": "Editar instalación", + "resource": "Software", + "type": "Tipo", + "os": "Sistema operativo", + "application": "Aplicación", + "description": "Registra el sistema operativo actual y el software instalado en este ordenador.", + "help": "La instalación pertenece al ordenador. Las aplicaciones de cada perfil documentan las necesidades y disponibilidad de cada persona. El sistema operativo de fábrica permanece en el activo. No se instalan programas ni se consumen licencias automáticamente.", + "noComputer": "Designa primero el ordenador principal del puesto.", + "empty": "No hay instalaciones registradas.", + "review": "Requiere revisión", + "loadError": "No se pudieron consultar las instalaciones y los recursos.", + "errors": { + "INVALID_REQUEST": "Revisa los datos de la instalación.", + "COMPANY_NOT_FOUND": "La compañía no existe.", + "SOFTWARE_CONFIGURATION_NOT_FOUND": "La instalación no existe.", + "SOFTWARE_REFERENCE_NOT_FOUND": "El ordenador o el recurso no pertenecen a esta compañía, o el recurso no es software.", + "SOFTWARE_REFERENCE_UNAVAILABLE": "El ordenador está retirado o el recurso está inactivo.", + "SOFTWARE_ALREADY_OPEN": "Ya hay una instalación abierta de este software en el ordenador.", + "SOFTWARE_TARGET_IMMUTABLE": "El ordenador y el recurso no pueden cambiar.", + "INVALID_SOFTWARE_TRANSITION": "Este cambio de estado no está permitido.", + "INTERNAL_ERROR": "No se pudo guardar la instalación." + }, + "examples": "Ejemplos: Windows 11 Pro 24H2, Thunderbird 140 o Microsoft 365 Apps.", + "showClosed": "Mostrar también las retiradas y nunca configuradas", + "newOs": "Nuevo sistema operativo" + } }, "access": { "helpTitle": "¿Qué es y para qué sirve?", diff --git a/modules/companies/src/common/software-installations.ts b/modules/companies/src/common/software-installations.ts new file mode 100644 index 0000000..5e06498 --- /dev/null +++ b/modules/companies/src/common/software-installations.ts @@ -0,0 +1,45 @@ +import { z } from "zod/v4"; + +import { SoftwareInstallationInputSchema } from "../api/domain/software-installation-values"; + +export type { SoftwareInstallationInput } from "../api/domain/software-installation-values"; +export { + SOFTWARE_STATE_TRANSITIONS, + SoftwareInstallationInputSchema, +} from "../api/domain/software-installation-values"; + +export const SoftwareParamsSchema = z.strictObject({ companyId: z.uuid() }); + +export const SoftwareItemParamsSchema = SoftwareParamsSchema.extend({ id: z.uuid() }); + +export const SaveSoftwareInstallationRequestSchema = z.strictObject({ + values: SoftwareInstallationInputSchema, +}); + +export const SoftwareInstallationSchema = z.strictObject({ + id: z.uuid(), + companyId: z.uuid(), + values: SoftwareInstallationInputSchema, + needsReview: z.boolean(), + history: z + .array( + z.strictObject({ + id: z.uuid(), + actorId: z.uuid(), + actorName: z.string().min(1), + recordedAt: z.iso.datetime(), + values: SoftwareInstallationInputSchema, + }) + ) + .min(1), +}); + +export const SoftwareInstallationsResponseSchema = z.strictObject({ + configurations: z.array(SoftwareInstallationSchema), +}); + +export const SoftwareInstallationResponseSchema = z.strictObject({ + configuration: SoftwareInstallationSchema, +}); + +export type SoftwareInstallationDTO = z.infer; diff --git a/modules/companies/src/web/software-installations/history/ui/software-history-dialog.tsx b/modules/companies/src/web/software-installations/history/ui/software-history-dialog.tsx new file mode 100644 index 0000000..e4dc3c1 --- /dev/null +++ b/modules/companies/src/web/software-installations/history/ui/software-history-dialog.tsx @@ -0,0 +1,49 @@ +import { FormDialog } from "@repo/rdx-ui/components/form-dialog"; +import { useTranslation } from "react-i18next"; + +import type { SoftwareInstallationDTO } from "../../../../common"; + +export function SoftwareHistoryDialog({ + configuration, + onClose, +}: { + onClose: () => void; + configuration: SoftwareInstallationDTO; +}) { + const { t, i18n } = useTranslation("companies"); + + return ( + { + if (!open) { + onClose(); + } + }} + open + title={t("mail.history")} + wide + > +
    + {[...configuration.history].reverse().map((revision) => ( +
  1. +

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

    +

    {t(`mail.${revision.values.state}`)}

    + {revision.values.version && ( +

    {t("applications.versionValue", { version: revision.values.version })}

    + )} + {revision.values.notes && ( +

    {revision.values.notes}

    + )} +
  2. + ))} +
+
+ ); +} diff --git a/modules/companies/src/web/software-installations/list/ui/software-installations-panel.tsx b/modules/companies/src/web/software-installations/list/ui/software-installations-panel.tsx new file mode 100644 index 0000000..451d86e --- /dev/null +++ b/modules/companies/src/web/software-installations/list/ui/software-installations-panel.tsx @@ -0,0 +1,248 @@ +import { BrowserLink, useListQuery } from "@metis/core/client"; +import { + catalogApi, + catalogKeys, + inventoryApi as resourceApi, + inventoryKeys as resourceKeys, +} from "@metis/resources/client"; +import { + EntityAction, + EntityListHeader, + EntityRowActions, + EntityStatus, + EntityTable, +} from "@repo/rdx-ui/components/entity-list"; +import { FormDialog } from "@repo/rdx-ui/components/form-dialog"; +import { Button } from "@repo/shadcn-ui/components/button"; +import { Checkbox } from "@repo/shadcn-ui/components/checkbox"; +import { useId, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { DeleteRecordDialog } from "../../../shared/ui/delete-record-dialog"; +import { SoftwareHistoryDialog } from "../../history/ui/software-history-dialog"; +import { softwareApi, softwareKeys } from "../../shared/api/software-api"; +import { SoftwareInstallationForm } from "../../shared/ui/software-installation-form"; + +export function SoftwareInstallationsPanel({ + companyId, + assetId, + canCreate = true, +}: { + companyId: string; + assetId: string | null | undefined; + canCreate?: boolean; +}) { + const { t, i18n } = useTranslation("companies"); + const id = useId(); + const [editor, setEditor] = useState(); + const [view, setView] = useState(); + const [deleting, setDeleting] = useState(); + const [search, setSearch] = useState(""); + const [closed, setClosed] = useState(false); + const query = useListQuery({ + queryKey: softwareKeys.company(companyId), + queryFn: () => softwareApi.get(companyId), + }); + const resources = useListQuery({ + queryKey: resourceKeys.company(companyId), + queryFn: () => resourceApi.get(companyId), + }); + const catalog = useListQuery({ + queryKey: catalogKeys.scope(companyId), + queryFn: () => catalogApi.get(companyId), + }); + const ready = query.isSuccess && resources.isSuccess && catalog.isSuccess; + + const resource = (resourceId: string) => + resources.data?.resources.find((item) => item.id === resourceId); + + const usage = (resourceId: string) => { + const item = resource(resourceId); + const template = catalog.data?.items.find( + (template) => template.id === item?.values.templateId + ); + + if (template?.builtinKey === "template:os") { + return "os"; + } + if (template?.builtinKey === "template:application") { + return "application"; + } + + return template?.values.kind === "template" ? template.values.usage : undefined; + }; + + const name = (resourceId: string) => resource(resourceId)?.values.name ?? "—"; + + const items = (query.data?.configurations ?? []).filter( + (item) => item.values.assetId === assetId + ); + const edited = items.find((item) => item.id === editor); + const viewed = items.find((item) => item.id === view); + const visible = items.filter( + (item) => + (closed || !["removed", "never_configured"].includes(item.values.state)) && + `${name(item.values.resourceId)} ${item.values.version ?? ""}` + .toLocaleLowerCase(i18n.language) + .includes(search.toLocaleLowerCase(i18n.language)) + ); + const options = (resources.data?.resources ?? []) + .filter( + (item) => + item.id === edited?.values.resourceId || + (item.values.active && ["os", "application"].includes(usage(item.id) ?? "")) + ) + .map((item) => ({ + value: item.id, + label: `${item.values.name} · ${t(`software.${usage(item.id) === "os" ? "os" : "application"}`)}`, + })); + + return ( +
+ setEditor("new")} + size="sm" + variant="outline" + > + {t("software.new")} + + } + description={t("software.description")} + locale={i18n.language} + title={t("software.title")} + /> + {!assetId &&

{t("software.noComputer")}

} + {(query.isPending || resources.isPending || catalog.isPending) && ( +

{t("loading")}

+ )} + {(query.isError || resources.isError || catalog.isError) && ( +
+

{t("software.loadError")}

+ +
+ )} + {assetId && ready && ( + name(item.values.resourceId), + render: (item) => ( + + {name(item.values.resourceId)} + + ), + }, + { + id: "type", + label: t("software.type"), + value: (item) => usage(item.values.resourceId), + render: (item) => + t(`software.${usage(item.values.resourceId) === "os" ? "os" : "application"}`), + }, + { + id: "version", + label: t("applications.version"), + value: (item) => item.values.version, + render: (item) => item.values.version || "—", + }, + { + id: "state", + label: t("status"), + value: (item) => item.values.state, + render: (item) => ( +
+ + {t(`mail.${item.values.state}`)} + + {item.needsReview && ( +

{t("software.review")}

+ )} +
+ ), + }, + { + id: "actions", + label: t("actions"), + hideable: false, + render: (item) => ( + + setView(item.id)}>{t("mail.history")} + {!["removed", "never_configured"].includes(item.values.state) && ( + setEditor(item.id)}>{t("edit")} + )} + setDeleting(item.id)}> + {t("deleteRecord.title")} + + + ), + }, + ]} + emptyMessage={t("software.empty")} + entityLabel={t("software.title")} + filters={ + + } + filtersApplied={closed} + items={visible} + onClearFilters={() => setClosed(false)} + onSearchChange={setSearch} + search={search} + searchPlaceholder={t("applications.search")} + title={t("software.title")} + /> + )} + {editor && assetId && ( + { + if (!open) { + setEditor(undefined); + } + }} + open + title={t(edited ? "software.edit" : "software.new")} + > + setEditor(undefined)} + options={options} + /> + + )} + {viewed && ( + setView(undefined)} /> + )} + setDeleting(undefined)} + path={deleting ? `/api/software-installations/${companyId}/${deleting}/delete` : undefined} + queryKey={softwareKeys.company(companyId)} + /> +
+ ); +} diff --git a/modules/companies/src/web/software-installations/shared/api/software-api.ts b/modules/companies/src/web/software-installations/shared/api/software-api.ts new file mode 100644 index 0000000..6d087cd --- /dev/null +++ b/modules/companies/src/web/software-installations/shared/api/software-api.ts @@ -0,0 +1,46 @@ +import { csrfFetch } from "@metis/core/client"; +import { z } from "zod/v4"; + +import type { SoftwareInstallationInput } from "../../../../common/software-installations"; +import { + SoftwareInstallationResponseSchema, + SoftwareInstallationsResponseSchema, +} from "../../../../common/software-installations"; + +export const softwareKeys = { company: (id: string) => ["software-installations", id] }; + +export class SoftwareApiError extends Error { + constructor(public readonly code: string) { + super(code); + } +} + +async function request(companyId: string, values?: SoftwareInstallationInput, id?: string) { + const response = await csrfFetch( + `/api/software-installations/${companyId}${id ? `/${id}/update` : ""}`, + { + method: values ? "POST" : "GET", + credentials: "same-origin", + headers: values ? { "content-type": "application/json" } : {}, + ...(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 SoftwareApiError(error.success ? error.data.code : "INTERNAL_ERROR"); + } + + return body; +} + +export const softwareApi = { + async get(companyId: string) { + return SoftwareInstallationsResponseSchema.parse(await request(companyId)); + }, + async save(companyId: string, values: SoftwareInstallationInput, id?: string) { + return SoftwareInstallationResponseSchema.parse(await request(companyId, values, id)); + }, +}; diff --git a/modules/companies/src/web/software-installations/shared/software.test.tsx b/modules/companies/src/web/software-installations/shared/software.test.tsx new file mode 100644 index 0000000..83919ce --- /dev/null +++ b/modules/companies/src/web/software-installations/shared/software.test.tsx @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { catalogKeys, inventoryKeys } from "@metis/resources/client"; +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 { SoftwareInstallationSchema } from "../../../common/software-installations"; +import { companiesEnglish, companiesSpanish } from "../../shared"; +import { SoftwareInstallationsPanel } from "../list/ui/software-installations-panel"; + +import { softwareKeys } from "./api/software-api"; +import { SoftwareInstallationForm } from "./ui/software-installation-form"; + +const id = (n: number) => `0199b200-0000-7000-8000-${String(n).padStart(12, "0")}`; + +const companyId = id(1); +const assetId = id(2); +const values = { assetId, resourceId: id(3), version: "24H2", state: "configured", notes: null }; +const item = SoftwareInstallationSchema.parse({ + id: id(4), + companyId, + values, + needsReview: false, + history: [ + { + id: id(5), + actorId: id(6), + actorName: "David", + recordedAt: "2026-10-12T10:00:00.000Z", + values, + }, + ], +}); + +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(softwareKeys.company(companyId), { + configurations: [ + item, + { ...item, id: id(10), values: { ...values, assetId: id(20), version: "OTHER-COMPUTER" } }, + ], + }); + client.setQueryData(inventoryKeys.company(companyId), { + resources: [{ id: id(3), values: { name: "Windows 11 Pro", active: true, templateId: id(7) } }], + }); + client.setQueryData(catalogKeys.scope(companyId), { + items: [{ id: id(7), builtinKey: "template:os", values: { kind: "template", usage: "os" } }], + }); + try { + return renderToStaticMarkup( + + {node} + + ); + } finally { + client.clear(); + } +} + +test("software del puesto es/en: SO actual, versión, enlaces y separación por ordenador sin formularios incrustados", async () => { + for (const language of ["es", "en"]) { + const html = await render( + language, + + ); + + assert.ok(html.includes("Windows 11 Pro")); + assert.ok(html.includes("24H2")); + assert.ok(html.includes(language === "es" ? "Sistema operativo" : "Operating system")); + assert.ok(html.includes(`href="/companies/${companyId}/resources/${id(3)}"`)); + assert.equal(html.includes("OTHER-COMPUTER"), false); + assert.equal(html.includes(" + ); + + assert.ok(empty.includes(language === "es" ? "Designa primero" : "Designate")); + assert.equal(empty.includes("Windows 11 Pro"), false); + } +}); + +test("formulario software: recurso fijo en edición, estados permitidos, ayuda y ausencia de persona/perfil", async () => { + const html = await render( + "es", + undefined} + options={[{ value: id(3), label: "Windows 11 Pro" }]} + /> + ); + + assert.ok(html.includes("La instalación pertenece al ordenador")); + assert.ok(html.includes("Guardar")); + assert.equal(html.includes("software.examples"), false); + assert.equal(html.includes('name="profileId"'), false); + assert.equal(html.includes('name="personId"'), false); +}); diff --git a/modules/companies/src/web/software-installations/shared/ui/software-installation-form.tsx b/modules/companies/src/web/software-installations/shared/ui/software-installation-form.tsx new file mode 100644 index 0000000..4e216fb --- /dev/null +++ b/modules/companies/src/web/software-installations/shared/ui/software-installation-form.tsx @@ -0,0 +1,170 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { ResourceEditor } from "@metis/resources/client"; +import { ContextHelp } from "@repo/rdx-ui/components/context-help"; +import { FormActions, FormBody, FormDialog } from "@repo/rdx-ui/components/form-dialog"; +import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action"; +import { Button } from "@repo/shadcn-ui/components/button"; +import { Input } from "@repo/shadcn-ui/components/input"; +import { Label } from "@repo/shadcn-ui/components/label"; +import { Textarea } from "@repo/shadcn-ui/components/textarea"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useId, useState } from "react"; +import { useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; + +import type { SoftwareInstallationDTO, SoftwareInstallationInput } from "../../../../common"; +import { SOFTWARE_STATE_TRANSITIONS, SoftwareInstallationInputSchema } from "../../../../common"; +import { SoftwareApiError, softwareApi, softwareKeys } from "../api/software-api"; + +export function SoftwareInstallationForm({ + companyId, + assetId, + installation, + options, + onSaved, +}: { + companyId: string; + assetId: string; + installation: SoftwareInstallationDTO | undefined; + options: readonly { value: string; label: string }[]; + onSaved: () => void; +}) { + const { t } = useTranslation("companies"); + const prefix = useId(); + const [quick, setQuick] = useState<"os" | "application">(); + const client = useQueryClient(); + const form = useForm({ + resolver: zodResolver(SoftwareInstallationInputSchema), + defaultValues: installation?.values ?? { + assetId, + resourceId: "", + version: null, + state: "pending", + notes: null, + }, + }); + const states = installation + ? SOFTWARE_STATE_TRANSITIONS[installation.values.state] + : (["pending", "configured"] as const); + const mutation = useMutation({ + mutationFn: (values: SoftwareInstallationInput) => + softwareApi.save(companyId, values, installation?.id), + onSuccess: async () => { + await client.invalidateQueries({ queryKey: softwareKeys.company(companyId) }); + onSaved(); + }, + }); + const error = mutation.error; + + return ( + <> +
mutation.mutate(values))} + > + +
+
+ + setQuick("os") } + } + disabled={!!installation} + id={`${prefix}-resource`} + label={t("software.resource")} + onValueChange={(value) => form.setValue("resourceId", value)} + options={options} + placeholder={t("mail.choose")} + required + value={form.watch("resourceId")} + /> +
+ {!installation && ( + + )} +
+
+ + +
+
+ + { + const parsed = SoftwareInstallationInputSchema.shape.state.safeParse(value); + + if (parsed.success) { + form.setValue("state", parsed.data); + } + }} + options={states.map((value) => ({ value, label: t(`mail.${value}`) }))} + placeholder={t("mail.choose")} + value={form.watch("state")} + /> +
+
+
+ +