From beee755f191c079656064a063b4efa09f59635ac Mon Sep 17 00:00:00 2001 From: David Date: Tue, 6 Oct 2026 17:15:42 +0200 Subject: [PATCH] Aplicaciones, certificados y entorno actual --- apps/server/src/applications.integration.ts | 214 ++++++++++ apps/server/src/certificates.integration.ts | 230 ++++++++++ apps/server/src/identity.test.ts | 18 + .../infrastructure/http/identity-runtime.ts | 5 +- docs/aplicaciones-del-perfil.md | 52 +++ .../modelo-ddd-e-identificadores.md | 8 + docs/certificados-del-perfil.md | 61 +++ docs/entorno-actual.md | 33 ++ docs/perfiles-de-trabajo.md | 2 +- docs/plan-de-desarrollo.md | 19 +- docs/recursos-campos-y-plantillas.md | 2 +- modules/companies/package.json | 2 +- .../application-configuration-input.mapper.ts | 47 ++ .../application-configuration-policy.ts | 29 ++ ...lication-configuration-response.builder.ts | 26 ++ .../application-configuration.repository.ts | 32 ++ .../application-configuration.use-cases.ts | 55 +++ .../certificate-configuration-input.mapper.ts | 46 ++ .../certificate-configuration-policy.ts | 33 ++ ...tificate-configuration-response.builder.ts | 30 ++ .../certificate-configuration.repository.ts | 34 ++ .../certificate-configuration.use-cases.ts | 55 +++ ...plication-configuration-revision.entity.ts | 25 ++ .../application-configuration-values.ts | 24 ++ .../application-configuration.aggregate.ts | 92 ++++ .../domain/application-configuration.test.ts | 227 ++++++++++ ...rtificate-configuration-revision.entity.ts | 25 ++ .../certificate-configuration-values.ts | 23 + .../certificate-configuration.aggregate.ts | 92 ++++ .../domain/certificate-configuration.test.ts | 261 ++++++++++++ .../application-configuration-values.ts | 69 +++ .../certificate-configuration-values.ts | 65 +++ .../http/application-configuration.routes.ts | 81 ++++ .../http/certificate-configuration.routes.ts | 81 ++++ .../application-configuration-migration.ts | 44 ++ ...cation-configuration-persistence.mapper.ts | 119 ++++++ .../certificate-configuration-migration.ts | 44 ++ ...ficate-configuration-persistence.mapper.ts | 119 ++++++ ...ze-application-configuration.repository.ts | 138 ++++++ ...ze-certificate-configuration.repository.ts | 139 ++++++ modules/companies/src/api/module.ts | 24 +- .../src/common/application-configurations.ts | 38 ++ .../src/common/certificate-configurations.ts | 40 ++ modules/companies/src/common/index.ts | 2 + .../src/web/applications/application-api.ts | 45 ++ .../application-configuration-form.tsx | 338 +++++++++++++++ .../application-configurations-page.tsx | 211 +++++++++ .../web/applications/application-feedback.tsx | 13 + .../src/web/applications/application-i18n.ts | 83 ++++ .../src/web/applications/application.test.tsx | 86 ++++ .../src/web/certificates/certificate-api.ts | 45 ++ .../certificate-configuration-form.tsx | 310 ++++++++++++++ .../certificate-configurations-page.tsx | 218 ++++++++++ .../web/certificates/certificate-feedback.tsx | 13 + .../src/web/certificates/certificate-i18n.ts | 82 ++++ .../src/web/certificates/certificate.test.tsx | 86 ++++ .../src/web/components/company-detail.tsx | 10 + modules/companies/src/web/i18n.ts | 9 + .../src/web/profiles/environment-i18n.ts | 86 ++++ .../web/profiles/work-environment-page.tsx | 400 ++++++++++++++++++ .../web/profiles/work-environment.model.ts | 266 ++++++++++++ .../web/profiles/work-environment.test.tsx | 348 +++++++++++++++ .../src/web/profiles/work-profiles-page.tsx | 62 ++- modules/resources/package.json | 3 +- .../application/resource-inventory.service.ts | 49 +++ .../api/domain/resource-definition.entity.ts | 1 + .../src/api/domain/resource-definition.ts | 1 + .../resource-definition-values.ts | 1 + modules/resources/src/api/module.ts | 5 +- modules/resources/src/api/services.ts | 1 + .../src/web/catalog-template-form.tsx | 33 ++ modules/resources/src/web/i18n.ts | 12 +- modules/resources/src/web/index.ts | 4 +- modules/resources/src/web/resource-editor.tsx | 2 +- modules/resources/src/web/resource-form.tsx | 15 +- 75 files changed, 5617 insertions(+), 26 deletions(-) create mode 100644 apps/server/src/applications.integration.ts create mode 100644 apps/server/src/certificates.integration.ts create mode 100644 docs/aplicaciones-del-perfil.md create mode 100644 docs/certificados-del-perfil.md create mode 100644 docs/entorno-actual.md create mode 100644 modules/companies/src/api/application/application-configuration-input.mapper.ts create mode 100644 modules/companies/src/api/application/application-configuration-policy.ts create mode 100644 modules/companies/src/api/application/application-configuration-response.builder.ts create mode 100644 modules/companies/src/api/application/application-configuration.repository.ts create mode 100644 modules/companies/src/api/application/application-configuration.use-cases.ts create mode 100644 modules/companies/src/api/application/certificate-configuration-input.mapper.ts create mode 100644 modules/companies/src/api/application/certificate-configuration-policy.ts create mode 100644 modules/companies/src/api/application/certificate-configuration-response.builder.ts create mode 100644 modules/companies/src/api/application/certificate-configuration.repository.ts create mode 100644 modules/companies/src/api/application/certificate-configuration.use-cases.ts create mode 100644 modules/companies/src/api/domain/application-configuration-revision.entity.ts create mode 100644 modules/companies/src/api/domain/application-configuration-values.ts create mode 100644 modules/companies/src/api/domain/application-configuration.aggregate.ts create mode 100644 modules/companies/src/api/domain/application-configuration.test.ts create mode 100644 modules/companies/src/api/domain/certificate-configuration-revision.entity.ts create mode 100644 modules/companies/src/api/domain/certificate-configuration-values.ts create mode 100644 modules/companies/src/api/domain/certificate-configuration.aggregate.ts create mode 100644 modules/companies/src/api/domain/certificate-configuration.test.ts create mode 100644 modules/companies/src/api/domain/value-objects/application-configuration-values.ts create mode 100644 modules/companies/src/api/domain/value-objects/certificate-configuration-values.ts create mode 100644 modules/companies/src/api/infrastructure/http/application-configuration.routes.ts create mode 100644 modules/companies/src/api/infrastructure/http/certificate-configuration.routes.ts create mode 100644 modules/companies/src/api/infrastructure/persistence/application-configuration-migration.ts create mode 100644 modules/companies/src/api/infrastructure/persistence/application-configuration-persistence.mapper.ts create mode 100644 modules/companies/src/api/infrastructure/persistence/certificate-configuration-migration.ts create mode 100644 modules/companies/src/api/infrastructure/persistence/certificate-configuration-persistence.mapper.ts create mode 100644 modules/companies/src/api/infrastructure/persistence/sequelize-application-configuration.repository.ts create mode 100644 modules/companies/src/api/infrastructure/persistence/sequelize-certificate-configuration.repository.ts create mode 100644 modules/companies/src/common/application-configurations.ts create mode 100644 modules/companies/src/common/certificate-configurations.ts create mode 100644 modules/companies/src/web/applications/application-api.ts create mode 100644 modules/companies/src/web/applications/application-configuration-form.tsx create mode 100644 modules/companies/src/web/applications/application-configurations-page.tsx create mode 100644 modules/companies/src/web/applications/application-feedback.tsx create mode 100644 modules/companies/src/web/applications/application-i18n.ts create mode 100644 modules/companies/src/web/applications/application.test.tsx create mode 100644 modules/companies/src/web/certificates/certificate-api.ts create mode 100644 modules/companies/src/web/certificates/certificate-configuration-form.tsx create mode 100644 modules/companies/src/web/certificates/certificate-configurations-page.tsx create mode 100644 modules/companies/src/web/certificates/certificate-feedback.tsx create mode 100644 modules/companies/src/web/certificates/certificate-i18n.ts create mode 100644 modules/companies/src/web/certificates/certificate.test.tsx create mode 100644 modules/companies/src/web/profiles/environment-i18n.ts create mode 100644 modules/companies/src/web/profiles/work-environment-page.tsx create mode 100644 modules/companies/src/web/profiles/work-environment.model.ts create mode 100644 modules/companies/src/web/profiles/work-environment.test.tsx create mode 100644 modules/resources/src/api/application/resource-inventory.service.ts create mode 100644 modules/resources/src/api/services.ts diff --git a/apps/server/src/applications.integration.ts b/apps/server/src/applications.integration.ts new file mode 100644 index 0000000..08b3b20 --- /dev/null +++ b/apps/server/src/applications.integration.ts @@ -0,0 +1,214 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; + +import { InventoryResponseSchema as AssetsSchema } from "@metis/assets/common"; +import { + type ApplicationConfigurationInput, + ApplicationConfigurationResponseSchema, + ApplicationConfigurationsResponseSchema, + CompanyResponseSchema, + WorkProfileResponseSchema, +} from "@metis/companies/common"; +import { CatalogResponseSchema, ResourceResponseSchema } from "@metis/resources/common"; +import type { Sequelize } from "sequelize"; +import { z } from "zod/v4"; + +type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise; +export async function verifyApplications( + request: Request, + companyId: string, + otherId: string, + database: Sequelize +) { + const personResponse = await request(`/companies/${companyId}/people`, { + name: "Persona aplicaciones", + position: null, + email: null, + phone: null, + active: true, + isContact: false, + }); + assert.equal(personResponse.status, 201); + const person = CompanyResponseSchema.parse(await personResponse.json()).company.people.at(-1); + assert.ok(person); + const assets = AssetsSchema.parse(await (await request(`/assets/${companyId}`)).json()); + const host = assets.assets.find((item) => item.values.status !== "retired"); + assert.ok(host); + const profileValues = { + name: "Perfil aplicaciones", + personId: person.id, + assetId: host.id, + accountId: null, + active: true, + notes: null, + }; + const profileResponse = await request(`/work-profiles/${companyId}`, { values: profileValues }); + assert.equal(profileResponse.status, 201); + const profile = WorkProfileResponseSchema.parse(await profileResponse.json()).profile; + const catalog = CatalogResponseSchema.parse( + await (await request(`/resource-catalog/${companyId}`)).json() + ); + const template = catalog.items.find((item) => item.builtinKey === "template:application"); + assert.ok(template); + assert.equal(template.values.kind, "template"); + const add = async (templateId: string, name: string) => { + const response = await request(`/resources/${companyId}`, { + values: { name, templateId, active: true, notes: "", characteristics: {} }, + }); + assert.equal(response.status, 201); + return ResourceResponseSchema.parse(await response.json()).resource; + }; + const app = await add(template.id, "Thunderbird prueba"); + const certificateTemplate = catalog.items.find( + (item) => item.builtinKey === "template:certificate" + ); + assert.ok(certificateTemplate); + const certificate = await add(certificateTemplate.id, "Certificado prueba aplicaciones"); + assert.ok(template.values.kind === "template"); + const customValues = { + ...template.values, + name: "Software personalizado", + usage: "application" as const, + }; + const customResponse = await request(`/resource-catalog/${companyId}`, { values: customValues }); + assert.equal(customResponse.status, 201); + const custom = CatalogResponseSchema.parse( + await (await request(`/resource-catalog/${companyId}`)).json() + ).items.find((item) => item.values.name === customValues.name); + assert.ok(custom); + const customApp = await add(custom.id, "ERP navegador prueba"); + const immutable = await request(`/resource-catalog/${companyId}/${custom.id}/update`, { + values: { ...customValues, usage: "certificate" }, + }); + assert.equal(immutable.status, 409); + const path = `/application-configurations/${companyId}`; + const input: ApplicationConfigurationInput = { + profileId: profile.id, + resourceId: app.id, + delivery: "installed", + version: null, + state: "pending", + notes: null, + }; + const save = async (values: ApplicationConfigurationInput, id?: string) => { + const response = await request(path + (id ? `/${id}/update` : ""), { values }); + assert.equal(response.status, id ? 200 : 201); + return ApplicationConfigurationResponseSchema.parse(await response.json()).configuration; + }; + const error = async (values: unknown, code: string, id?: string, status = 409) => { + const response = await request(path + (id ? `/${id}/update` : ""), { values }); + assert.equal(response.status, status); + assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code); + }; + await error({ ...input, actorId: person.id }, "INVALID_REQUEST", undefined, 400); + await error( + { ...input, resourceId: randomUUID() }, + "APPLICATION_REFERENCE_NOT_FOUND", + undefined, + 404 + ); + await error( + { ...input, resourceId: certificate.id }, + "APPLICATION_REFERENCE_NOT_FOUND", + undefined, + 404 + ); + await error({ ...input, delivery: "web", version: "1" }, "INVALID_REQUEST", undefined, 400); + assert.equal((await request(path, { values: input }, "https://other.example")).status, 403); + assert.equal((await request(path, { values: input }, undefined, "a".repeat(64))).status, 403); + const attempts = await Promise.all([ + request(path, { values: input }), + request(path, { values: input }), + ]); + assert.deepEqual(attempts.map((r) => r.status).sort(), [201, 409]); + const response = attempts.find((r) => r.status === 201); + assert.ok(response); + const created = ApplicationConfigurationResponseSchema.parse(await response.json()).configuration; + assert.equal(created.id[14], "7"); + assert.equal(created.history[0]?.id[14], "7"); + assert.deepEqual(await save(input, created.id), created); + const configured = await save( + { ...input, state: "configured", version: "128", notes: "Solicitado por Ana" }, + created.id + ); + assert.equal(configured.history.length, 2); + assert.equal(configured.history[0]?.values.version, null); + await save({ ...input, resourceId: customApp.id, delivery: "web", state: "configured" }); + await error({ ...input, resourceId: customApp.id }, "APPLICATION_TARGET_IMMUTABLE", created.id); + await error({ ...input, state: "removed" }, "INVALID_APPLICATION_TRANSITION", created.id); + await database.query("UPDATE application_configurations SET currentKey=NULL WHERE id=:id", { + replacements: { id: created.id }, + }); + try { + assert.equal((await request(path)).status, 500); + } finally { + await database.query("UPDATE application_configurations SET currentKey=:key WHERE id=:id", { + replacements: { id: created.id, key: `${profile.id}:${app.id}` }, + }); + } + const deactivate = await request(`/resources/${companyId}/${app.id}/update`, { + values: { ...app.values, active: false }, + }); + assert.equal(deactivate.status, 200); + const list = ApplicationConfigurationsResponseSchema.parse( + await (await request(path)).json() + ).configurations; + assert.equal(list.find((item) => item.id === created.id)?.needsReview, true); + await error( + { ...configured.values, version: "129" }, + "APPLICATION_REFERENCE_UNAVAILABLE", + created.id + ); + const noted = await save({ ...configured.values, notes: "Retirar al salir" }, created.id); + assert.equal(noted.history.length, 3); + const removal = await save({ ...noted.values, state: "removal_pending" }, created.id); + const removed = await save({ ...removal.values, state: "removed" }, created.id); + assert.equal(removed.needsReview, false); + await error({ ...removed.values, notes: "Otra" }, "INVALID_APPLICATION_TRANSITION", created.id); + assert.equal( + ( + await request(`/application-configurations/${otherId}/${created.id}/update`, { + values: input, + }) + ).status, + 404 + ); + assert.equal( + ApplicationConfigurationsResponseSchema.parse( + await (await request(`/application-configurations/${otherId}`)).json() + ).configurations.length, + 0 + ); + // Un recurso compartido por perfiles mantiene configuraciones independientes. + const secondPersonResponse = await request(`/companies/${companyId}/people`, { + name: "Otra persona aplicaciones", + position: null, + email: null, + phone: null, + active: true, + isContact: false, + }); + const secondPerson = CompanyResponseSchema.parse( + await secondPersonResponse.json() + ).company.people.at(-1); + assert.ok(secondPerson); + const secondProfile = await request(`/work-profiles/${companyId}`, { + values: { ...profileValues, name: "Otro perfil aplicaciones", personId: secondPerson.id }, + }); + assert.equal(secondProfile.status, 201); + const secondId = WorkProfileResponseSchema.parse(await secondProfile.json()).profile.id; + await save({ ...input, profileId: secondId, resourceId: customApp.id, delivery: "web" }); + const archive = await request(`/work-profiles/${companyId}/${secondId}/update`, { + values: { + ...profileValues, + name: "Otro perfil aplicaciones", + personId: secondPerson.id, + active: false, + }, + }); + assert.equal(archive.status, 200); + const reviewed = ApplicationConfigurationsResponseSchema.parse( + await (await request(path)).json() + ).configurations; + assert.equal(reviewed.find((item) => item.values.profileId === secondId)?.needsReview, true); +} diff --git a/apps/server/src/certificates.integration.ts b/apps/server/src/certificates.integration.ts new file mode 100644 index 0000000..b11683c --- /dev/null +++ b/apps/server/src/certificates.integration.ts @@ -0,0 +1,230 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; + +import { InventoryResponseSchema as AssetsSchema } from "@metis/assets/common"; +import { + type CertificateConfigurationInput, + CertificateConfigurationResponseSchema, + CertificateConfigurationsResponseSchema, + CompanyResponseSchema, + WorkProfileResponseSchema, +} from "@metis/companies/common"; +import { CatalogResponseSchema, ResourceResponseSchema } from "@metis/resources/common"; +import type { Sequelize } from "sequelize"; +import { z } from "zod/v4"; + +type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise; +export async function verifyCertificates( + request: Request, + companyId: string, + otherId: string, + database: Sequelize +) { + const personResponse = await request(`/companies/${companyId}/people`, { + name: "Persona certificados", + position: null, + email: null, + phone: null, + active: true, + isContact: false, + }); + assert.equal(personResponse.status, 201); + const person = CompanyResponseSchema.parse(await personResponse.json()).company.people.at(-1); + assert.ok(person); + const assets = AssetsSchema.parse(await (await request(`/assets/${companyId}`)).json()); + const host = assets.assets.find((item) => item.values.status !== "retired"); + assert.ok(host); + const profileValues = { + name: "Perfil certificados", + personId: person.id, + assetId: host.id, + accountId: null, + active: true, + notes: null, + }; + const profileResponse = await request(`/work-profiles/${companyId}`, { values: profileValues }); + assert.equal(profileResponse.status, 201); + const profile = WorkProfileResponseSchema.parse(await profileResponse.json()).profile; + const catalog = CatalogResponseSchema.parse( + await (await request(`/resource-catalog/${companyId}`)).json() + ); + const template = catalog.items.find((item) => item.builtinKey === "template:certificate"); + assert.ok(template); + assert.equal(template.values.kind, "template"); + const add = async (templateId: string, name: string) => { + const response = await request(`/resources/${companyId}`, { + values: { name, templateId, active: true, notes: "", characteristics: {} }, + }); + assert.equal(response.status, 201); + return ResourceResponseSchema.parse(await response.json()).resource; + }; + const app = await add(template.id, "Certificado empresa prueba"); + const certificateTemplate = catalog.items.find( + (item) => item.builtinKey === "template:application" + ); + assert.ok(certificateTemplate); + const certificate = await add(certificateTemplate.id, "Aplicación ajena al certificado"); + assert.ok(template.values.kind === "template"); + const customValues = { + ...template.values, + name: "Certificados personalizados", + usage: "certificate" as const, + }; + const customResponse = await request(`/resource-catalog/${companyId}`, { values: customValues }); + assert.equal(customResponse.status, 201); + const custom = CatalogResponseSchema.parse( + await (await request(`/resource-catalog/${companyId}`)).json() + ).items.find((item) => item.values.name === customValues.name); + assert.ok(custom); + const customApp = await add(custom.id, "Certificado colaboración prueba"); + const immutable = await request(`/resource-catalog/${companyId}/${custom.id}/update`, { + values: { ...customValues, usage: "application" }, + }); + assert.equal(immutable.status, 409); + const path = `/certificate-configurations/${companyId}`; + const input: CertificateConfigurationInput = { + profileId: profile.id, + resourceId: app.id, + location: null, + state: "pending", + notes: null, + }; + const save = async (values: CertificateConfigurationInput, id?: string) => { + const response = await request(path + (id ? `/${id}/update` : ""), { values }); + assert.equal(response.status, id ? 200 : 201); + return CertificateConfigurationResponseSchema.parse(await response.json()).configuration; + }; + const error = async (values: unknown, code: string, id?: string, status = 409) => { + const response = await request(path + (id ? `/${id}/update` : ""), { values }); + assert.equal(response.status, status); + assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code); + }; + await error({ ...input, actorId: person.id }, "INVALID_REQUEST", undefined, 400); + await error( + { ...input, resourceId: randomUUID() }, + "CERTIFICATE_REFERENCE_NOT_FOUND", + undefined, + 404 + ); + await error( + { ...input, resourceId: certificate.id }, + "CERTIFICATE_REFERENCE_NOT_FOUND", + undefined, + 404 + ); + await error({ ...input, privateKey: "forbidden" }, "INVALID_REQUEST", undefined, 400); + assert.equal((await request(path, { values: input }, "https://other.example")).status, 403); + assert.equal((await request(path, { values: input }, undefined, "a".repeat(64))).status, 403); + const attempts = await Promise.all([ + request(path, { values: input }), + request(path, { values: input }), + ]); + assert.deepEqual(attempts.map((r) => r.status).sort(), [201, 409]); + const response = attempts.find((r) => r.status === 201); + assert.ok(response); + const created = CertificateConfigurationResponseSchema.parse(await response.json()).configuration; + assert.equal(created.id[14], "7"); + assert.equal(created.history[0]?.id[14], "7"); + assert.deepEqual(await save(input, created.id), created); + const configured = await save( + { + ...input, + state: "configured", + location: "Windows del usuario", + notes: "Necesario para firmar", + }, + created.id + ); + assert.equal(configured.history.length, 2); + assert.equal(configured.history[0]?.values.location, null); + await save({ ...input, resourceId: customApp.id, location: "Firefox", state: "configured" }); + await error({ ...input, resourceId: customApp.id }, "CERTIFICATE_TARGET_IMMUTABLE", created.id); + await error({ ...input, state: "removed" }, "INVALID_CERTIFICATE_TRANSITION", created.id); + await database.query("UPDATE certificate_configurations SET currentKey=NULL WHERE id=:id", { + replacements: { id: created.id }, + }); + try { + assert.equal((await request(path)).status, 500); + } finally { + await database.query("UPDATE certificate_configurations SET currentKey=:key WHERE id=:id", { + replacements: { id: created.id, key: `${profile.id}:${app.id}` }, + }); + } + const expiry = catalog.items.find((item) => item.builtinKey === "field:expires"); + assert.ok(expiry); + const expiredResource = await request(`/resources/${companyId}/${app.id}/update`, { + values: { + ...app.values, + characteristics: { ...app.values.characteristics, [expiry.id]: "2000-01-01" }, + }, + }); + assert.equal(expiredResource.status, 200); + const list = CertificateConfigurationsResponseSchema.parse( + await (await request(path)).json() + ).configurations; + assert.equal(list.find((item) => item.id === created.id)?.needsReview, true); + assert.equal(list.find((item) => item.id === created.id)?.expired, true); + assert.equal(list.find((item) => item.id === created.id)?.expiresOn, "2000-01-01"); + await error( + { ...configured.values, location: "Otro almacén" }, + "CERTIFICATE_REFERENCE_UNAVAILABLE", + created.id + ); + const noted = await save({ ...configured.values, notes: "Retirar al salir" }, created.id); + assert.equal(noted.history.length, 3); + const removal = await save({ ...noted.values, state: "removal_pending" }, created.id); + const removed = await save({ ...removal.values, state: "removed" }, created.id); + assert.equal(removed.needsReview, false); + await error({ ...removed.values, notes: "Otra" }, "INVALID_CERTIFICATE_TRANSITION", created.id); + assert.equal( + ( + await request(`/certificate-configurations/${otherId}/${created.id}/update`, { + values: input, + }) + ).status, + 404 + ); + assert.equal( + CertificateConfigurationsResponseSchema.parse( + await (await request(`/certificate-configurations/${otherId}`)).json() + ).configurations.length, + 0 + ); + // Un recurso compartido por perfiles mantiene configuraciones independientes. + const secondPersonResponse = await request(`/companies/${companyId}/people`, { + name: "Otra persona certificados", + position: null, + email: null, + phone: null, + active: true, + isContact: false, + }); + const secondPerson = CompanyResponseSchema.parse( + await secondPersonResponse.json() + ).company.people.at(-1); + assert.ok(secondPerson); + const secondProfile = await request(`/work-profiles/${companyId}`, { + values: { ...profileValues, name: "Otro perfil certificados", personId: secondPerson.id }, + }); + assert.equal(secondProfile.status, 201); + const secondId = WorkProfileResponseSchema.parse(await secondProfile.json()).profile.id; + await save({ + ...input, + profileId: secondId, + resourceId: customApp.id, + location: "Tarjeta criptográfica", + }); + const archive = await request(`/work-profiles/${companyId}/${secondId}/update`, { + values: { + ...profileValues, + name: "Otro perfil certificados", + personId: secondPerson.id, + active: false, + }, + }); + assert.equal(archive.status, 200); + const reviewed = CertificateConfigurationsResponseSchema.parse( + await (await request(path)).json() + ).configurations; + assert.equal(reviewed.find((item) => item.values.profileId === secondId)?.needsReview, true); +} diff --git a/apps/server/src/identity.test.ts b/apps/server/src/identity.test.ts index d9f4e79..f9d56c8 100644 --- a/apps/server/src/identity.test.ts +++ b/apps/server/src/identity.test.ts @@ -21,7 +21,9 @@ import { z } from "zod/v4"; import { verifyAccess } from "./access.integration"; import { createApp } from "./app"; +import { verifyApplications } from "./applications.integration"; import { verifyAssets } from "./assets.integration"; +import { verifyCertificates } from "./certificates.integration"; import { createDatabase, readDatabaseEnvironment } from "./config/database"; import { createIdentityRuntime } from "./infrastructure/http/identity-runtime"; import { verifyInventory } from "./inventory.integration"; @@ -56,6 +58,10 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de await database.query("DELETE FROM folder_mappings"); await database.query("DELETE FROM printer_configuration_revisions"); await database.query("DELETE FROM printer_configurations"); + await database.query("DELETE FROM application_configuration_revisions"); + await database.query("DELETE FROM application_configurations"); + await database.query("DELETE FROM certificate_configuration_revisions"); + await database.query("DELETE FROM certificate_configurations"); await database.query("DELETE FROM work_profile_revisions"); await database.query("DELETE FROM work_profiles"); await database.query("DELETE FROM workstation_assets"); @@ -757,6 +763,18 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de secondCompany.id, database ); + await verifyApplications( + (...args) => operator.request(...args), + customer.id, + secondCompany.id, + database + ); + await verifyCertificates( + (...args) => operator.request(...args), + customer.id, + secondCompany.id, + database + ); await verifyPrinters( (...args) => operator.request(...args), customer.id, diff --git a/apps/server/src/infrastructure/http/identity-runtime.ts b/apps/server/src/infrastructure/http/identity-runtime.ts index 8006c1e..9a3f089 100644 --- a/apps/server/src/infrastructure/http/identity-runtime.ts +++ b/apps/server/src/infrastructure/http/identity-runtime.ts @@ -24,7 +24,10 @@ export async function createIdentityRuntime( const assets = await createAssetModule(database, identity.authenticate); const resources = await createResourceModule(database, identity.authenticate); const mail = await companies.initializeMail(assets.services.inventory); - const profiles = await companies.initializeProfiles(assets.services.inventory); + const profiles = await companies.initializeProfiles( + assets.services.inventory, + resources.services.inventory + ); await resources.migrateLegacy(); const SequelizeStore = connectSequelize(session.Store); const store = new SequelizeStore({ diff --git a/docs/aplicaciones-del-perfil.md b/docs/aplicaciones-del-perfil.md new file mode 100644 index 0000000..06742df --- /dev/null +++ b/docs/aplicaciones-del-perfil.md @@ -0,0 +1,52 @@ +# Aplicaciones del perfil + +## Uso + +Aplicaciones del perfil está disponible en la compañía y desde cada perfil de trabajo. El listado permite buscar por aplicación, perfil, persona y equipo. El alta y la edición se abren mediante modal; la ayuda aparece inmediatamente antes de Guardar y Cancelar. + +La aplicación es un Resource de la compañía. Se seleccionan recursos de la plantilla de serie Aplicación o de una plantilla personalizada con uso Aplicación. El nombre no determina el tipo. Las copias de las plantillas de aplicación y certificado heredan su uso; las plantillas nuevas permiten elegirlo. El uso queda fijo tras el alta, igual que el tipo de un campo. Los recursos existentes y sus especificaciones no se reescriben. + +Una configuración relaciona un perfil y un recurso, ambos fijos. No exige cuenta informática ni licencia. Una aplicación puede utilizarse en varios perfiles, con estados y versiones independientes. Un perfil puede necesitar varias aplicaciones. No hay duplicados abiertos de la misma pareja. + +## Necesidad y disponibilidad + +Pendiente de configurar documenta una necesidad. Configurado documenta que se ha comprobado su disponibilidad. Se usa un único registro con historial para seguir la preparación y la retirada, sin mantener dos listados de necesidades y configuraciones que puedan divergir. + +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. + +## Estados e historial + +- Pendiente → Configurado o Nunca configurado. +- Configurado → Pendiente de retirar. +- Pendiente de retirar → Retirado. +- Retirado y Nunca configurado son terminales y se conservan sin edición. +- En estados abiertos pueden cambiar forma de uso, versión y notas, con historial. +- Una pareja cerrada permite crear una nueva configuración con otro UUID. + +Crear o reconfigurar en estado pendiente/configurado exige perfil y persona activos, equipo no retirado y recurso activo de la compañía. Si se archiva el perfil o se desactiva la persona/recurso, la configuración abierta muestra revisión. Sigue siendo posible añadir notas y registrar la retirada. Desactivar una plantilla no invalida sus recursos existentes. + +Los selectores Perfil y Aplicación permiten altas rápidas con SelectWithAction. En la consulta filtrada el perfil queda fijado. El alta rápida de aplicación reutiliza el editor de recursos, preselecciona Aplicación y permite sus plantillas personalizadas. No abre un tercer modal. Cancelar el hijo conserva el padre; un hijo ya guardado permanece aunque se cancele el padre. + +## Arquitectura y API + +ApplicationConfiguration es AggregateRoot; ApplicationConfigurationRevision es DomainEntity con UUID propio; ApplicationConfigurationValues es Value Object inmutable con UniqueID y Maybe. Las identidades nuevas son UUID v7. Una edición semánticamente equivalente no escribe una revisión. La rehidratación comprueba identidad, pareja fija, estados, orden, fechas, clave abierta y coherencia de la última revisión. + +application_configurations y application_configuration_revisions se crean con migración explícita e idempotente. Las claves foráneas apuntan a compañía, perfil, Resource y actor. La clave nullable de pareja abierta impide duplicados. Las escrituras bloquean la compañía y guardan raíz y revisiones nuevas en la misma transacción. + +Companies consume un servicio público de Resources para consultar identidad, estado y uso del recurso; no importa sus modelos ORM. El servicio reconoce las plantillas de serie por clave estable y las propias por uso explícito. Los catálogos de activos y recursos siguen separados. + +GET /api/application-configurations/:companyId lista configuraciones y avisos. POST en esa ruta crea; POST /api/application-configurations/:companyId/:id/update edita. Sesión obligatoria, origen y CSRF en escrituras, actor procedente de sesión y entrada estricta. + +## Validación + +Pruebas de dominio: contratos, ausencia y límites, versión solo instalada, UUID, igualdad semántica, historial, destino fijo, ciclo de retirada, ámbito, clasificación y disponibilidad, rehidratación corrupta. + +Pruebas HTTP con MariaDB de test: plantillas de serie y personalizadas, rechazo de certificados, concurrencia, idempotencia, versiones auditadas, aislamiento entre compañías, origen/CSRF, corrupción de clave abierta, desactivación del recurso, archivo del perfil y aplicaciones compartidas entre perfiles. + +Pruebas de interfaz: traducciones es/en, listado sin formularios, altas rápidas y ayuda inferior. Comprobación en navegador del modal principal, selección de perfil, cambio instalada/web y alta rápida de recurso, sin guardar registros ficticios en desarrollo. + +Los [certificados por perfil](certificados-del-perfil.md) están implementados; la ficha [Entorno actual](entorno-actual.md) también está implementada. + +Comprobaciones de esta entrega: 109 pruebas pasan, incluida una segunda ejecución completa contra MariaDB de test. Typecheck y build pasan; los 42 archivos de código y configuración modificados pasan Biome. Lint y check globales mantienen errores anteriores de carpetas (32 y 90 respectivamente), sin errores nuevos en este bloque. En navegador se verifica también el perfil fijo, ambas altas rápidas, conservación al cancelar y limpieza de versión al elegir acceso web. diff --git a/docs/arquitectura/modelo-ddd-e-identificadores.md b/docs/arquitectura/modelo-ddd-e-identificadores.md index 945a06d..e5e2849 100644 --- a/docs/arquitectura/modelo-ddd-e-identificadores.md +++ b/docs/arquitectura/modelo-ddd-e-identificadores.md @@ -66,3 +66,11 @@ PrinterConfiguration es la raíz de una impresora configurada en un perfil y Pri Los módulos de documentos y tickets todavía no existen. Se declararán como agregados o entidades al implementar sus operaciones y reglas de consistencia. La decisión de pertenencia a un agregado se toma por sus invariantes y transacciones; no convierte automáticamente cada tabla en una raíz ni fuerza todo el cliente y sus tickets dentro de un solo agregado. Cada entidad que se incorpore deberá tener UniqueID desde su primera implementación, mapper de persistencia y pruebas de identidad, invariantes, rehidratación y actualización. + +## Aplicaciones por perfil + +ApplicationConfiguration es AggregateRoot; ApplicationConfigurationRevision es DomainEntity con UUID propio. ApplicationConfigurationValues es Value Object inmutable con UniqueID para perfil/recurso y Maybe para versión/notas. Crear genera UUID v7, rehidratar conserva identidad e historial y editar compara semánticamente antes de añadir revisión. + +## Certificados por perfil + +CertificateConfiguration es AggregateRoot; CertificateConfigurationRevision es DomainEntity con UUID propio. CertificateConfigurationValues es Value Object inmutable con UniqueID para perfil/recurso y Maybe para ubicación/notas. Crear genera UUID v7, rehidratar conserva identidad e historial y editar compara semánticamente antes de añadir revisión. La caducidad se consulta a través del servicio público de Resources, sin duplicarla en el agregado. diff --git a/docs/certificados-del-perfil.md b/docs/certificados-del-perfil.md new file mode 100644 index 0000000..e9c5eae --- /dev/null +++ b/docs/certificados-del-perfil.md @@ -0,0 +1,61 @@ +# Certificados del perfil + +## Uso + +Certificados del perfil está disponible en la compañía y desde cada perfil. Permite buscar por certificado, perfil, persona y equipo. Alta y edición se abren mediante modal, con ayuda inmediatamente antes de Guardar y Cancelar. + +El certificado es un Resource de la compañía, creado con la plantilla de serie Certificado digital o una plantilla propia con uso Certificado digital. El nombre no determina el tipo. Titular, emisor, huella digital, finalidad y caducidad pertenecen al recurso común. Los campos siguen siendo opcionales por defecto. + +La configuración enlaza un perfil y un recurso, ambos fijos. Estado y notas describen su preparación y retirada. Almacén o ubicación es texto opcional, hasta 255 caracteres: Windows del usuario, Firefox, tarjeta criptográfica o varias ubicaciones indicadas conjuntamente. No se exige conocer el almacén para registrar la necesidad. No hay campos para archivos, contraseñas, PIN o claves privadas. + +Un certificado puede usarse en varios perfiles y un perfil puede necesitar varios certificados. Cada configuración conserva su propio historial. No puede haber dos configuraciones abiertas para la misma pareja. + +## Necesidad, configuración y retirada + +Pendiente de configurar documenta lo que la persona necesita; Configurado documenta su disponibilidad comprobada. Se conserva un único registro con historial, sin duplicar necesidades y configuraciones. + +Estados: +- Pendiente → Configurado o Nunca configurado. +- Configurado → Pendiente de retirar. +- Pendiente de retirar → Retirado. +- Retirado y Nunca configurado son terminales e inmutables; se permite repetir una edición equivalente. +- Cerrar una pareja permite registrar otra configuración con nuevo UUID. +- Ubicación y notas pueden editarse en estados abiertos. + +Crear o reconfigurar en estados pendiente/configurado exige perfil y persona activos, equipo no retirado y certificado activo y no caducado. Si una referencia deja de estar disponible, la configuración abierta muestra revisión. Se permite añadir notas y documentar la retirada aun con referencias inactivas o caducadas. + +Esta configuración registra información para el técnico: no instala certificados, concede autorizaciones, elimina certificados del ordenador ni retira permisos reales. + +## Caducidad + +Se consulta el campo global de serie Caducidad mediante su UUID y clave estable, sin depender de su nombre. Funciona en la plantilla de serie y en plantillas personalizadas que reutilizan ese campo. No se interpretan otros campos de fecha como caducidad solo por llamarse igual. + +Una fecha ausente no impide configurar. La fecha indicada es inclusiva: se considera caducado a partir del día siguiente, con día UTC como criterio común del servidor. La fecha y el indicador caducado se calculan al consultar la configuración y no se almacenan de nuevo. Cambiar la caducidad en el recurso actualiza todas sus configuraciones. + +El listado muestra caducidad cuando se conoce y avisa si ha caducado. Una configuración cerrada conserva su historial sin marcar retirada pendiente. No se incorporan todavía notificaciones programadas ni gestión de renovaciones. + +## Altas rápidas + +Perfil y Certificado digital reutilizan SelectWithAction. Al acceder desde un perfil, este queda fijado. El alta rápida de certificado reutiliza ResourceEditor, preselecciona Certificado digital y limita las plantillas a su uso; el formulario hijo no abre un tercer modal. Cancelar el hijo conserva el formulario principal. Los recursos o perfiles guardados permanecen aunque se cancele el principal. + +## Arquitectura y persistencia + +CertificateConfiguration es AggregateRoot; CertificateConfigurationRevision es DomainEntity con UUID propio; CertificateConfigurationValues es Value Object inmutable con UniqueID y Maybe para ubicación/notas. Las nuevas identidades son UUID v7. Ediciones semánticamente iguales no añaden revisiones. Create, update y rehydrate mantienen responsabilidades separadas. + +certificate_configurations y certificate_configuration_revisions se crean con migración explícita e idempotente, claves foráneas y clave nullable única de pareja abierta. Las escrituras bloquean la compañía y guardan raíz y revisiones nuevas en una transacción. La rehidratación comprueba identidad, pareja fija, estado inicial, transiciones, fechas, orden y coherencia de última revisión y clave abierta. + +Companies consume IResourceInventoryService para identidad, uso, disponibilidad y caducidad. No importa los modelos ORM de Resources. Los campos y plantillas de recursos siguen separados de activos. + +GET /api/certificate-configurations/:companyId lista configuraciones, revisión y caducidad calculada. POST en esa ruta crea; POST /api/certificate-configurations/:companyId/:id/update edita. Requieren sesión; origen y CSRF protegen escrituras; el actor procede de sesión. Entrada y respuesta se validan con schemas estrictos. + +## Pruebas + +Dominio: ubicación opcional, normalización y límites, entrada estricta, UUID, idempotencia, historial, destino fijo, duplicados, cierre terminal, ámbito y clasificación, inactividad, caducidad inclusiva, notas y retirada del caducado, rehidratación corrupta. + +HTTP con MariaDB de test: plantillas de serie y propias, rechazo de aplicaciones como certificados, concurrencia, idempotencia, historial de ubicación, caducidad del recurso propagada a la configuración, aislamiento, origen/CSRF, corrupción de clave abierta, configuración compartida entre perfiles y archivo del perfil. + +Interfaz: es/en, listado sin formularios, altas rápidas, ubicación opcional y ayuda inferior. La comprobación en navegador abre el modal y las altas rápidas, verifica cancelación y el perfil fijo, sin guardar datos ficticios en desarrollo. + +La ficha [Entorno actual](entorno-actual.md) reúne las configuraciones del perfil. + +Comprobaciones de esta entrega: 117 pruebas pasan, incluida una segunda ejecución completa con MariaDB de test. Typecheck y build pasan. Los 64 archivos de código y configuración de los cambios locales pasan Biome. Lint y check globales mantienen los errores anteriores de carpetas (32 y 90), sin errores nuevos en este bloque. diff --git a/docs/entorno-actual.md b/docs/entorno-actual.md new file mode 100644 index 0000000..216fb83 --- /dev/null +++ b/docs/entorno-actual.md @@ -0,0 +1,33 @@ +# Entorno actual + +Ficha de consulta disponible desde «Entorno actual» en cada perfil de trabajo. Reúne la persona, el dispositivo, la cuenta de inicio de sesión y las configuraciones registradas para ese perfil. + +## Equipo y puesto + +Muestra las características del equipo que tienen valor, conservando valores como No o cero. Consulta los puestos activos que contienen ese dispositivo y presenta sus activos exclusivos y compartidos sin duplicarlos. No infiere asignaciones ni titularidades entre persona, perfil y puesto. Las relaciones físicas y el mapa visual permanecen en sus consultas actuales; no se incorpora un mapa nuevo. + +## Configuraciones + +Los cinco bloques son correos, carpetas compartidas, impresoras, aplicaciones y certificados. Cada fila conserva método, ruta, permisos documentados, conexión, modalidad, versión o ubicación según corresponda; también estado, notas y aviso de revisión. + +Una persona puede tener varios correos y varios perfiles. La ficha muestra solamente las configuraciones del perfil seleccionado; permite cambiar a otros perfiles vigentes de esa persona. Las configuraciones retiradas o nunca configuradas quedan en las consultas de gestión e historial. Los correos antiguos sin perfil requieren vinculación explícita: se avisa de los registros del mismo dispositivo y persona, sin asignarlos automáticamente. + +Los contadores distinguen configuradas, pendientes de configurar, pendientes de retirar y por revisar. La revisión puede coincidir con otro estado; los contadores no son categorías excluyentes. El filtro de atención muestra preparación, retirada y revisión. Una carpeta sin cobertura documentada requiere revisión. Un certificado caducado mantiene el aviso recibido del servidor. + +«Gestionar» abre cada consulta filtrada, con sus modales habituales y acceso al historial. Volver conserva el perfil de la ficha. Un perfil archivado se identifica como tal y muestra el último estado registrado, sin presentarlo como entorno vigente. También se advierte de persona inactiva y perfil por revisar. + +La ausencia de registros no demuestra que el equipo esté listo. No se inspeccionan automáticamente dispositivos ni se confunden configuraciones documentadas con autorizaciones. + +## Arquitectura + +Proyección de lectura en Companies sobre los servicios HTTP existentes de perfiles, activos, recursos, acceso y configuraciones. No crea entidades persistentes ni duplica datos. Los catálogos de activos y recursos siguen separados. Todas las referencias se filtran por compañía y perfil. + +Las consultas reutilizan las claves e invalidaciones de cada gestión. Una carga incompleta o un error presenta carga/error y reintento; no se muestra una ficha parcial como si estuviera completa. La interfaz tiene traducciones es/en. + +## Validación + +Pruebas de proyección e interfaz: varios buzones, aislamiento por compañía y perfil, estados cerrados excluidos, correo antiguo sin vinculación automática, puestos vigentes y activos sin duplicar, nombre actual del recurso, valores booleanos, alertas y contadores, traducciones, carga, fallo con datos en caché, perfil inexistente y perfil archivado. + +Comprobaciones de esta entrega: 122 pruebas pasan, incluidos los cinco casos nuevos de la ficha y la integración con MariaDB de test. Typecheck y build pasan. Los siete archivos de código y configuración de este bloque pasan Biome. Lint y check globales mantienen los errores previos (32 y 90 respectivamente), sin nuevos errores de este bloque. + +En navegador se comprueban los datos reales del perfil, el filtro de atención, las consultas de correos y aplicaciones, el regreso al mismo perfil y el formulario modal con perfil fijo y cancelación. No se crean registros ficticios en desarrollo. diff --git a/docs/perfiles-de-trabajo.md b/docs/perfiles-de-trabajo.md index cb81840..f58be4c 100644 --- a/docs/perfiles-de-trabajo.md +++ b/docs/perfiles-de-trabajo.md @@ -42,6 +42,6 @@ Las pruebas cubren cuentas opcionales, parejas vigentes únicas, múltiples pers La base del perfil incorpora ahora [buzones y configuraciones de correo](correos-en-dispositivos.md). Cada perfil puede tener varios buzones y cada configuración conserva su método y estado. La acción «Correos en dispositivos» de la fila abre la consulta filtrada del perfil. No se confunde la cuenta de inicio de sesión con el buzón. -El perfil incorpora también carpetas compartidas e [impresoras por perfil](impresoras-del-perfil.md), con acciones para abrir cada consulta filtrada. Programas, certificados y la vista completa Entorno actual siguen pendientes. El mapa visual queda aplazado. +El perfil incorpora también carpetas compartidas e [impresoras por perfil](impresoras-del-perfil.md), con acciones para abrir cada consulta filtrada. Las [aplicaciones por perfil](aplicaciones-del-perfil.md) están implementadas sobre recursos. Los [certificados por perfil](certificados-del-perfil.md) también están implementados. La ficha [Entorno actual](entorno-actual.md) reúne estas configuraciones. El mapa visual queda aplazado. La comprobación en navegador verifica la nueva sección, los desplegables de persona y dispositivo, la apertura de las tres altas rápidas y la conservación del formulario al cancelarlas. En puestos se comprueba la carga de una composición anterior, el selector compartido, su incorporación visual y la cancelación sin persistir cambios. diff --git a/docs/plan-de-desarrollo.md b/docs/plan-de-desarrollo.md index f18d41c..fae1c67 100644 --- a/docs/plan-de-desarrollo.md +++ b/docs/plan-de-desarrollo.md @@ -41,7 +41,7 @@ Catálogo global con 30 campos y 12 plantillas de serie protegidas, cinco tipos ## Perfiles de trabajo: base implementada -Persona y dispositivo fijos, cuenta de inicio de sesión opcional, perfiles vigentes y archivados, múltiples personas/dispositivos, historial y avisos de revisión. Consultar [Perfiles de trabajo](perfiles-de-trabajo.md). La vista completa Entorno actual y sus configuraciones se incorporarán progresivamente; el mapa visual queda aplazado. +Persona y dispositivo fijos, cuenta de inicio de sesión opcional, perfiles vigentes y archivados, múltiples personas/dispositivos, historial y avisos de revisión. Consultar [Perfiles de trabajo](perfiles-de-trabajo.md). La ficha Entorno actual reúne las configuraciones implementadas; el mapa visual queda aplazado. ## Buzones y correo por perfil: implementado @@ -55,11 +55,22 @@ Catálogo independiente de activos, 13 campos y cinco plantillas de serie, adici Configuraciones USB o red sobre activos de la compañía, una predeterminada por perfil, estados de preparación y retirada, historial y avisos de revisión. Disponible desde la compañía y cada perfil. Modales, ayuda inferior y altas rápidas. Consultar [Impresoras del perfil](impresoras-del-perfil.md). +## Aplicaciones por perfil: implementado + +Recursos de plantilla Aplicación o plantilla personalizada con uso Aplicación, instaladas o de acceso web, versión opcional, estados de preparación y retirada, historial y revisión. Modales y altas rápidas de recurso/perfil. Consultar [Aplicaciones del perfil](aplicaciones-del-perfil.md). + +## Certificados por perfil: implementado + +Recursos de plantilla Certificado digital o plantilla personalizada con ese uso, ubicación opcional, estados de preparación y retirada, historial y revisión por inactividad o caducidad. Modales y altas rápidas de recurso/perfil. Consultar [Certificados del perfil](certificados-del-perfil.md). + +## Entorno actual: implementado + +Ficha de consulta por perfil con equipo, características registradas, composición de los puestos vigentes, cuenta de inicio y configuraciones abiertas de correo, carpetas, impresoras, aplicaciones y certificados. Resumen de preparación, retirada y revisión; filtro de atención y acceso a cada gestión. Consultar [Entorno actual](entorno-actual.md). + ## Siguientes entregas: orden actualizado -1. Configuraciones del perfil: aplicaciones y certificados como recursos; después ficha del entorno actual reuniendo correos, carpetas, impresoras y los nuevos recursos. -2. Teletrabajo y accesos remotos: autorización, equipo de origen, conectividad VPN y destino. Asistencia técnica remota independiente. -3. Procesos de alta, cambio y salida con seguimiento de configuraciones y retiradas. Plantillas reutilizables entre compañías; ejecuciones y datos propios. +1. Teletrabajo y accesos remotos: autorización, equipo de origen, conectividad VPN y destino. Asistencia técnica remota independiente. +2. Procesos de alta, cambio y salida con seguimiento de configuraciones y retiradas. Plantillas reutilizables entre compañías; ejecuciones y datos propios. Este orden se concreta antes de cada entrega. Las licencias y su relación con cuentas se explorarán posteriormente. diff --git a/docs/recursos-campos-y-plantillas.md b/docs/recursos-campos-y-plantillas.md index 7b09aed..165fbf0 100644 --- a/docs/recursos-campos-y-plantillas.md +++ b/docs/recursos-campos-y-plantillas.md @@ -12,7 +12,7 @@ Las altas y ediciones se abren en modal desde la cabecera o una acción explíci Un campo representa una característica reutilizable de recursos. Admite texto, número, fecha, sí/no o lista de valores; nombre obligatorio, descripción y unidad opcionales. El tipo queda fijo tras el alta. En las listas se pueden añadir valores, pero no retirar los existentes. -Una plantilla reúne campos de recursos. Los campos comienzan como opcionales; en plantillas propias pueden marcarse obligatorios. Se puede retirar un campo de una plantilla sin borrar su definición. +Una plantilla reúne campos de recursos. Las plantillas propias pueden declarar uso Aplicación o Certificado digital para sus configuraciones por perfil; General conserva el comportamiento anterior. El uso queda fijo al crearla y las copias de serie heredan su uso, sin deducirlo por nombre. Aplicaciones y certificados por perfil están implementados. La caducidad de un certificado se consulta desde el recurso, sin duplicarse en el perfil. Los campos comienzan como opcionales; en plantillas propias pueden marcarse obligatorios. Se puede retirar un campo de una plantilla sin borrar su definición. Hay definiciones globales y adiciones por compañía. Una plantilla global solo admite campos globales. Una plantilla local admite campos globales y de su propia compañía. Los nombres del mismo tipo no se duplican en el catálogo visible, ignorando mayúsculas y espacios exteriores. diff --git a/modules/companies/package.json b/modules/companies/package.json index 225cc1c..683e9ee 100644 --- a/modules/companies/package.json +++ b/modules/companies/package.json @@ -10,7 +10,7 @@ }, "scripts": { "typecheck": "tsc -p tsconfig.json", - "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/components/companies-page.test.tsx src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/api/domain/mailbox.test.ts src/web/access/access.test.tsx src/web/mail/mail.test.tsx src/api/domain/work-profile.test.ts src/web/profiles/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printers/printer.test.tsx" + "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/components/companies-page.test.tsx src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/api/domain/mailbox.test.ts src/web/access/access.test.tsx src/web/mail/mail.test.tsx src/api/domain/work-profile.test.ts src/web/profiles/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printers/printer.test.tsx src/api/domain/application-configuration.test.ts src/web/applications/application.test.tsx src/api/domain/certificate-configuration.test.ts src/web/certificates/certificate.test.tsx src/web/profiles/work-environment.test.tsx" }, "dependencies": { "@repo/rdx-ddd": "workspace:*", diff --git a/modules/companies/src/api/application/application-configuration-input.mapper.ts b/modules/companies/src/api/application/application-configuration-input.mapper.ts new file mode 100644 index 0000000..c5bace5 --- /dev/null +++ b/modules/companies/src/api/application/application-configuration-input.mapper.ts @@ -0,0 +1,47 @@ +import { Result } from "@repo/rdx-utils"; + +import type { ApplicationConfiguration } from "../domain/application-configuration.aggregate"; +import { + ApplicationConfigurationError, + type ApplicationConfigurationInput, +} from "../domain/application-configuration-values"; +import { ApplicationConfigurationValues } from "../domain/value-objects/application-configuration-values"; + +import type { ApplicationContext } from "./application-configuration.repository"; +import { applicationAvailable, applicationReferences } from "./application-configuration-policy"; +export function mapApplicationInput( + input: ApplicationConfigurationInput, + context: ApplicationContext, + previous?: ApplicationConfiguration +) { + const parsed = ApplicationConfigurationValues.create(input); + if (!parsed.isSuccess) { + return parsed; + } + const values = parsed.data; + if (previous && !previous.values.sameTarget(values)) { + return Result.fail(new ApplicationConfigurationError("APPLICATION_TARGET_IMMUTABLE")); + } + if (!applicationReferences(values, context)) { + return Result.fail(new ApplicationConfigurationError("APPLICATION_REFERENCE_NOT_FOUND")); + } + const reconfiguring = + !previous || + previous.values.state !== values.state || + previous.values.delivery !== values.delivery || + previous.values.version.getOrUndefined() !== values.version.getOrUndefined(); + if ( + ["pending", "configured"].includes(values.state) && + reconfiguring && + !applicationAvailable(values, context) + ) { + return Result.fail(new ApplicationConfigurationError("APPLICATION_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 ApplicationConfigurationError("APPLICATION_ALREADY_OPEN")); + } + return parsed; +} diff --git a/modules/companies/src/api/application/application-configuration-policy.ts b/modules/companies/src/api/application/application-configuration-policy.ts new file mode 100644 index 0000000..9319bcf --- /dev/null +++ b/modules/companies/src/api/application/application-configuration-policy.ts @@ -0,0 +1,29 @@ +import type { ApplicationConfigurationValues } from "../domain/value-objects/application-configuration-values"; + +import type { ApplicationContext } from "./application-configuration.repository"; +export function applicationReferences( + values: ApplicationConfigurationValues, + context: ApplicationContext +) { + return ( + context.profiles.some((item) => item.id.equals(values.profileId)) && + context.resources.some( + (item) => item.id.equals(values.resourceId) && item.usage === "application" + ) + ); +} +export function applicationAvailable( + values: ApplicationConfigurationValues, + context: ApplicationContext +) { + const profile = context.profiles.find((item) => item.id.equals(values.profileId)); + return ( + !!profile && + profile.values.active && + context.people.some((person) => person.id.equals(profile.values.personId) && person.active) && + context.assets.some((asset) => asset.id.equals(profile.values.assetId) && asset.available) && + context.resources.some( + (item) => item.id.equals(values.resourceId) && item.active && item.usage === "application" + ) + ); +} diff --git a/modules/companies/src/api/application/application-configuration-response.builder.ts b/modules/companies/src/api/application/application-configuration-response.builder.ts new file mode 100644 index 0000000..b262e4e --- /dev/null +++ b/modules/companies/src/api/application/application-configuration-response.builder.ts @@ -0,0 +1,26 @@ +import type { ApplicationConfigurationDTO } from "../../common/application-configurations"; +import type { ApplicationConfiguration } from "../domain/application-configuration.aggregate"; + +import type { ApplicationContext } from "./application-configuration.repository"; +import { applicationAvailable } from "./application-configuration-policy"; +export function buildApplicationResponse( + item: ApplicationConfiguration, + context: ApplicationContext +): ApplicationConfigurationDTO { + return { + id: item.id.toPrimitive(), + companyId: item.companyId.toPrimitive(), + values: item.values.toPrimitive(), + needsReview: + !item.values.closed && + (!applicationAvailable(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/application-configuration.repository.ts b/modules/companies/src/api/application/application-configuration.repository.ts new file mode 100644 index 0000000..6fe31a9 --- /dev/null +++ b/modules/companies/src/api/application/application-configuration.repository.ts @@ -0,0 +1,32 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import type { Result } from "@repo/rdx-utils"; + +import type { ApplicationConfiguration } from "../domain/application-configuration.aggregate"; +import type { ApplicationConfigurationError } from "../domain/application-configuration-values"; +import type { WorkProfile } from "../domain/work-profile.aggregate"; +export type ApplicationContext = Readonly<{ + configurations: readonly ApplicationConfiguration[]; + profiles: readonly WorkProfile[]; + people: readonly { id: UniqueID; active: boolean }[]; + assets: readonly { id: UniqueID; available: boolean }[]; + resources: readonly { + id: UniqueID; + active: boolean; + usage: "application" | "certificate" | null; + }[]; + actors: ReadonlyMap; +}>; +export interface IApplicationConfigurationRepository { + get(company: UniqueID): Promise>; + save( + company: UniqueID, + action: ( + context: ApplicationContext + ) => Result + ): Promise< + Result< + { configuration: ApplicationConfiguration; context: ApplicationContext }, + ApplicationConfigurationError + > + >; +} diff --git a/modules/companies/src/api/application/application-configuration.use-cases.ts b/modules/companies/src/api/application/application-configuration.use-cases.ts new file mode 100644 index 0000000..f795b8e --- /dev/null +++ b/modules/companies/src/api/application/application-configuration.use-cases.ts @@ -0,0 +1,55 @@ +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { ApplicationConfiguration } from "../domain/application-configuration.aggregate"; +import { + ApplicationConfigurationError, + type ApplicationConfigurationInput, +} from "../domain/application-configuration-values"; + +import type { IApplicationConfigurationRepository } from "./application-configuration.repository"; +import { mapApplicationInput } from "./application-configuration-input.mapper"; +export class ApplicationConfigurationUseCases { + constructor(private readonly repository: IApplicationConfigurationRepository) {} + get(companyId: string) { + const company = UniqueID.create(companyId); + return company.isSuccess + ? this.repository.get(company.data) + : Promise.resolve(Result.fail(new ApplicationConfigurationError("INVALID_REQUEST"))); + } + save( + companyId: string, + id: string | undefined, + input: ApplicationConfigurationInput, + 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 ApplicationConfigurationError("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 ApplicationConfigurationError("APPLICATION_CONFIGURATION_NOT_FOUND") + ); + } + const values = mapApplicationInput(input, context, previous); + if (!values.isSuccess) { + return values; + } + const now = new Date().toISOString(); + const candidate = previous + ? previous.update(values.data, actor.data, now) + : ApplicationConfiguration.create(company.data, values.data, actor.data, now); + if (!candidate.isSuccess) { + return candidate; + } + return candidate; + }); + } +} diff --git a/modules/companies/src/api/application/certificate-configuration-input.mapper.ts b/modules/companies/src/api/application/certificate-configuration-input.mapper.ts new file mode 100644 index 0000000..7726afd --- /dev/null +++ b/modules/companies/src/api/application/certificate-configuration-input.mapper.ts @@ -0,0 +1,46 @@ +import { Result } from "@repo/rdx-utils"; + +import type { CertificateConfiguration } from "../domain/certificate-configuration.aggregate"; +import { + CertificateConfigurationError, + type CertificateConfigurationInput, +} from "../domain/certificate-configuration-values"; +import { CertificateConfigurationValues } from "../domain/value-objects/certificate-configuration-values"; + +import type { CertificateContext } from "./certificate-configuration.repository"; +import { certificateAvailable, certificateReferences } from "./certificate-configuration-policy"; +export function mapCertificateInput( + input: CertificateConfigurationInput, + context: CertificateContext, + previous?: CertificateConfiguration +) { + const parsed = CertificateConfigurationValues.create(input); + if (!parsed.isSuccess) { + return parsed; + } + const values = parsed.data; + if (previous && !previous.values.sameTarget(values)) { + return Result.fail(new CertificateConfigurationError("CERTIFICATE_TARGET_IMMUTABLE")); + } + if (!certificateReferences(values, context)) { + return Result.fail(new CertificateConfigurationError("CERTIFICATE_REFERENCE_NOT_FOUND")); + } + const reconfiguring = + !previous || + previous.values.state !== values.state || + previous.values.location.getOrUndefined() !== values.location.getOrUndefined(); + if ( + ["pending", "configured"].includes(values.state) && + reconfiguring && + !certificateAvailable(values, context) + ) { + return Result.fail(new CertificateConfigurationError("CERTIFICATE_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 CertificateConfigurationError("CERTIFICATE_ALREADY_OPEN")); + } + return parsed; +} diff --git a/modules/companies/src/api/application/certificate-configuration-policy.ts b/modules/companies/src/api/application/certificate-configuration-policy.ts new file mode 100644 index 0000000..19a369e --- /dev/null +++ b/modules/companies/src/api/application/certificate-configuration-policy.ts @@ -0,0 +1,33 @@ +import type { CertificateConfigurationValues } from "../domain/value-objects/certificate-configuration-values"; + +import type { CertificateContext } from "./certificate-configuration.repository"; +export function certificateReferences( + values: CertificateConfigurationValues, + context: CertificateContext +) { + return ( + context.profiles.some((item) => item.id.equals(values.profileId)) && + context.resources.some( + (item) => item.id.equals(values.resourceId) && item.usage === "certificate" + ) + ); +} +export function certificateAvailable( + values: CertificateConfigurationValues, + context: CertificateContext +) { + const profile = context.profiles.find((item) => item.id.equals(values.profileId)); + return ( + !!profile && + profile.values.active && + context.people.some((person) => person.id.equals(profile.values.personId) && person.active) && + context.assets.some((asset) => asset.id.equals(profile.values.assetId) && asset.available) && + context.resources.some( + (item) => + item.id.equals(values.resourceId) && + item.active && + item.usage === "certificate" && + (!item.expiresOn || item.expiresOn >= context.today) + ) + ); +} diff --git a/modules/companies/src/api/application/certificate-configuration-response.builder.ts b/modules/companies/src/api/application/certificate-configuration-response.builder.ts new file mode 100644 index 0000000..235acd6 --- /dev/null +++ b/modules/companies/src/api/application/certificate-configuration-response.builder.ts @@ -0,0 +1,30 @@ +import type { CertificateConfigurationDTO } from "../../common/certificate-configurations"; +import type { CertificateConfiguration } from "../domain/certificate-configuration.aggregate"; + +import type { CertificateContext } from "./certificate-configuration.repository"; +import { certificateAvailable } from "./certificate-configuration-policy"; +export function buildCertificateResponse( + item: CertificateConfiguration, + context: CertificateContext +): CertificateConfigurationDTO { + const resource = context.resources.find((resource) => resource.id.equals(item.values.resourceId)); + const expiresOn = resource?.expiresOn ?? null; + return { + id: item.id.toPrimitive(), + companyId: item.companyId.toPrimitive(), + values: item.values.toPrimitive(), + expiresOn, + expired: expiresOn !== null && expiresOn < context.today, + needsReview: + !item.values.closed && + (!certificateAvailable(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/certificate-configuration.repository.ts b/modules/companies/src/api/application/certificate-configuration.repository.ts new file mode 100644 index 0000000..e532d70 --- /dev/null +++ b/modules/companies/src/api/application/certificate-configuration.repository.ts @@ -0,0 +1,34 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import type { Result } from "@repo/rdx-utils"; + +import type { CertificateConfiguration } from "../domain/certificate-configuration.aggregate"; +import type { CertificateConfigurationError } from "../domain/certificate-configuration-values"; +import type { WorkProfile } from "../domain/work-profile.aggregate"; +export type CertificateContext = Readonly<{ + today: string; + configurations: readonly CertificateConfiguration[]; + profiles: readonly WorkProfile[]; + people: readonly { id: UniqueID; active: boolean }[]; + assets: readonly { id: UniqueID; available: boolean }[]; + resources: readonly { + id: UniqueID; + active: boolean; + usage: "application" | "certificate" | null; + expiresOn: string | null; + }[]; + actors: ReadonlyMap; +}>; +export interface ICertificateConfigurationRepository { + get(company: UniqueID): Promise>; + save( + company: UniqueID, + action: ( + context: CertificateContext + ) => Result + ): Promise< + Result< + { configuration: CertificateConfiguration; context: CertificateContext }, + CertificateConfigurationError + > + >; +} diff --git a/modules/companies/src/api/application/certificate-configuration.use-cases.ts b/modules/companies/src/api/application/certificate-configuration.use-cases.ts new file mode 100644 index 0000000..c235418 --- /dev/null +++ b/modules/companies/src/api/application/certificate-configuration.use-cases.ts @@ -0,0 +1,55 @@ +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { CertificateConfiguration } from "../domain/certificate-configuration.aggregate"; +import { + CertificateConfigurationError, + type CertificateConfigurationInput, +} from "../domain/certificate-configuration-values"; + +import type { ICertificateConfigurationRepository } from "./certificate-configuration.repository"; +import { mapCertificateInput } from "./certificate-configuration-input.mapper"; +export class CertificateConfigurationUseCases { + constructor(private readonly repository: ICertificateConfigurationRepository) {} + get(companyId: string) { + const company = UniqueID.create(companyId); + return company.isSuccess + ? this.repository.get(company.data) + : Promise.resolve(Result.fail(new CertificateConfigurationError("INVALID_REQUEST"))); + } + save( + companyId: string, + id: string | undefined, + input: CertificateConfigurationInput, + 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 CertificateConfigurationError("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 CertificateConfigurationError("CERTIFICATE_CONFIGURATION_NOT_FOUND") + ); + } + const values = mapCertificateInput(input, context, previous); + if (!values.isSuccess) { + return values; + } + const now = new Date().toISOString(); + const candidate = previous + ? previous.update(values.data, actor.data, now) + : CertificateConfiguration.create(company.data, values.data, actor.data, now); + if (!candidate.isSuccess) { + return candidate; + } + return candidate; + }); + } +} diff --git a/modules/companies/src/api/domain/application-configuration-revision.entity.ts b/modules/companies/src/api/domain/application-configuration-revision.entity.ts new file mode 100644 index 0000000..a9a699b --- /dev/null +++ b/modules/companies/src/api/domain/application-configuration-revision.entity.ts @@ -0,0 +1,25 @@ +import { DomainEntity, type UniqueID } from "@repo/rdx-ddd"; + +import type { ApplicationConfigurationValues } from "./value-objects/application-configuration-values"; +export type ApplicationConfigurationRevisionProps = Readonly<{ + values: ApplicationConfigurationValues; + actorId: UniqueID; + recordedAt: string; +}>; +export class ApplicationConfigurationRevision extends DomainEntity { + static create(props: ApplicationConfigurationRevisionProps) { + return new ApplicationConfigurationRevision(Object.freeze({ ...props })); + } + static rehydrate(props: ApplicationConfigurationRevisionProps, id: UniqueID) { + return new ApplicationConfigurationRevision(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/application-configuration-values.ts b/modules/companies/src/api/domain/application-configuration-values.ts new file mode 100644 index 0000000..a88e22e --- /dev/null +++ b/modules/companies/src/api/domain/application-configuration-values.ts @@ -0,0 +1,24 @@ +import { z } from "zod/v4"; + +import { ConfigurationSchema } from "./access/access-definition"; +export const ApplicationConfigurationInputSchema = z.strictObject({ + profileId: z.uuid(), + resourceId: z.uuid(), + delivery: z.enum(["installed", "web"]), + version: z.string().trim().max(100).nullable(), + state: ConfigurationSchema, + notes: z.string().trim().max(4000).nullable(), +}); +export type ApplicationConfigurationInput = z.infer; +export class ApplicationConfigurationError extends Error { + constructor(public readonly code: string) { + super(code); + } +} +export const APPLICATION_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/application-configuration.aggregate.ts b/modules/companies/src/api/domain/application-configuration.aggregate.ts new file mode 100644 index 0000000..fc18a05 --- /dev/null +++ b/modules/companies/src/api/domain/application-configuration.aggregate.ts @@ -0,0 +1,92 @@ +import { AggregateRoot, type UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import { z } from "zod/v4"; + +import { ApplicationConfigurationRevision } from "./application-configuration-revision.entity"; +import { + APPLICATION_STATE_TRANSITIONS, + ApplicationConfigurationError, +} from "./application-configuration-values"; +import type { ApplicationConfigurationValues } from "./value-objects/application-configuration-values"; + +type Props = Readonly<{ + companyId: UniqueID; + values: ApplicationConfigurationValues; + history: readonly ApplicationConfigurationRevision[]; +}>; +export class ApplicationConfiguration extends AggregateRoot { + private constructor(props: Props, id?: UniqueID) { + super(Object.freeze({ ...props, history: Object.freeze([...props.history]) }), id); + } + static create( + companyId: UniqueID, + values: ApplicationConfigurationValues, + actorId: UniqueID, + recordedAt: string + ): Result { + if ( + !( + z.iso.datetime().safeParse(recordedAt).success && + ["pending", "configured"].includes(values.state) + ) + ) { + return Result.fail(new ApplicationConfigurationError("INVALID_APPLICATION_TRANSITION")); + } + return Result.ok( + new ApplicationConfiguration({ + companyId, + values, + history: [ApplicationConfigurationRevision.create({ values, actorId, recordedAt })], + }) + ); + } + static rehydrate(props: Props, id: UniqueID) { + return new ApplicationConfiguration(props, id); + } + get companyId() { + return this.props.companyId; + } + get values() { + return this.props.values; + } + get history() { + return this.props.history; + } + update( + values: ApplicationConfigurationValues, + actorId: UniqueID, + recordedAt: string + ): Result { + if (this.values.equals(values)) { + return Result.ok(this); + } + if (!this.values.sameTarget(values)) { + return Result.fail(new ApplicationConfigurationError("APPLICATION_TARGET_IMMUTABLE")); + } + if ( + !APPLICATION_STATE_TRANSITIONS[this.values.state].some((state) => state === values.state) || + (this.values.closed && !this.values.equals(values)) + ) { + return Result.fail(new ApplicationConfigurationError("INVALID_APPLICATION_TRANSITION")); + } + if ( + !z.iso.datetime().safeParse(recordedAt).success || + recordedAt < (this.history.at(-1)?.recordedAt ?? "") + ) { + return Result.fail(new ApplicationConfigurationError("INVALID_REQUEST")); + } + return Result.ok( + new ApplicationConfiguration( + { + ...this.props, + values, + history: [ + ...this.history, + ApplicationConfigurationRevision.create({ values, actorId, recordedAt }), + ], + }, + this.id + ) + ); + } +} diff --git a/modules/companies/src/api/domain/application-configuration.test.ts b/modules/companies/src/api/domain/application-configuration.test.ts new file mode 100644 index 0000000..b3d0596 --- /dev/null +++ b/modules/companies/src/api/domain/application-configuration.test.ts @@ -0,0 +1,227 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { UniqueID } from "@repo/rdx-ddd"; + +import type { ApplicationContext } from "../application/application-configuration.repository"; +import { mapApplicationInput } from "../application/application-configuration-input.mapper"; +import { buildApplicationResponse } from "../application/application-configuration-response.builder"; +import { ApplicationConfigurationPersistenceMapper } from "../infrastructure/persistence/application-configuration-persistence.mapper"; + +import { ApplicationConfiguration } from "./application-configuration.aggregate"; +import { + type ApplicationConfigurationInput, + ApplicationConfigurationInputSchema, +} from "./application-configuration-values"; +import { ApplicationConfigurationValues } from "./value-objects/application-configuration-values"; +import { WorkProfileValues } from "./value-objects/work-profile-values"; +import { WorkProfile } from "./work-profile.aggregate"; + +const company = UniqueID.generateNewID(); +const actor = UniqueID.generateNewID(); +const person = UniqueID.generateNewID(); +const host = UniqueID.generateNewID(); +const resource = UniqueID.generateNewID(); +const date = "2026-10-06T12:00:00.000Z"; +function fixture() { + const value = WorkProfileValues.create({ + name: "Ana", + personId: person.toPrimitive(), + assetId: host.toPrimitive(), + accountId: null, + active: true, + notes: null, + }); + assert.ok(value.isSuccess); + const profile = WorkProfile.create(company, value.data, actor, date); + assert.ok(profile.isSuccess); + const input: ApplicationConfigurationInput = { + profileId: profile.data.id.toPrimitive(), + resourceId: resource.toPrimitive(), + delivery: "installed", + version: null, + state: "pending", + notes: null, + }; + const context: ApplicationContext = { + profiles: [profile.data], + configurations: [], + people: [{ id: person, active: true }], + assets: [{ id: host, available: true }], + resources: [{ id: resource, active: true, usage: "application" }], + actors: new Map([[actor.toPrimitive(), "Técnico"]]), + }; + return { input, context }; +} +function values(input: ApplicationConfigurationInput) { + const value = ApplicationConfigurationValues.create(input); + assert.ok(value.isSuccess); + return value.data; +} +function create(input: ApplicationConfigurationInput) { + const root = ApplicationConfiguration.create(company, values(input), actor, date); + assert.ok(root.isSuccess); + return root.data; +} +test("aplicaciones: contrato estricto, versión opcional y solo instalada", () => { + const { input } = fixture(); + assert.equal( + ApplicationConfigurationInputSchema.safeParse({ ...input, actorId: actor.toPrimitive() }) + .success, + false + ); + assert.equal( + ApplicationConfigurationValues.create({ ...input, delivery: "web", version: "1" }).isSuccess, + false + ); + assert.equal(values({ ...input, version: " ", notes: " " }).toPrimitive().version, null); + assert.equal(values({ ...input, delivery: "web" }).toPrimitive().version, null); + assert.equal( + ApplicationConfigurationValues.create({ ...input, version: "a".repeat(101) }).isSuccess, + false + ); + assert.equal( + ApplicationConfigurationValues.create({ ...input, notes: "a".repeat(4001) }).isSuccess, + false + ); +}); +test("aplicaciones: UUID, idempotencia, versión auditada, destino fijo y retirada", () => { + const { input } = fixture(); + const root = create(input); + assert.equal(root.id.toPrimitive()[14], "7"); + assert.equal(root.history[0]?.id.toPrimitive()[14], "7"); + const same = root.update(values({ ...input, notes: " " }), actor, date); + assert.ok(same.isSuccess); + assert.equal(same.data, root); + const configured = root.update( + values({ ...input, state: "configured", version: "128" }), + actor, + date + ); + assert.ok(configured.isSuccess); + assert.equal(configured.data.history.length, 2); + assert.equal(configured.data.history[0]?.values.version.getOrUndefined(), undefined); + assert.equal( + configured.data.update( + values({ ...input, resourceId: UniqueID.generateNewID().toPrimitive() }), + actor, + date + ).isSuccess, + false + ); + const removal = configured.data.update( + values({ ...configured.data.values.toPrimitive(), state: "removal_pending" }), + actor, + date + ); + assert.ok(removal.isSuccess); + const removed = removal.data.update( + values({ ...removal.data.values.toPrimitive(), state: "removed" }), + actor, + date + ); + assert.ok(removed.isSuccess); + assert.equal( + removed.data.update( + values({ ...removed.data.values.toPrimitive(), notes: "Otra" }), + actor, + date + ).isSuccess, + false + ); + assert.equal( + root.update(values({ ...input, state: "configured" }), actor, "2026-10-05T12:00:00.000Z") + .isSuccess, + false + ); + assert.equal( + ApplicationConfiguration.create(company, values({ ...input, state: "removed" }), actor, date) + .isSuccess, + false + ); +}); +test("aplicaciones: ámbito y clasificación explícita, duplicados abiertos y reapertura nueva", () => { + const { input, context } = fixture(); + const root = create(input); + const duplicate = mapApplicationInput(input, { ...context, configurations: [root] }); + assert.ok(!duplicate.isSuccess); + assert.equal(duplicate.error.code, "APPLICATION_ALREADY_OPEN"); + assert.equal( + mapApplicationInput({ ...input, resourceId: host.toPrimitive() }, context).isSuccess, + false + ); + assert.equal( + mapApplicationInput(input, { + ...context, + resources: [{ id: resource, active: true, usage: "certificate" }], + }).isSuccess, + false + ); + assert.equal( + mapApplicationInput(input, { + ...context, + resources: [{ id: resource, active: true, usage: null }], + }).isSuccess, + false + ); + const closed = root.update(values({ ...input, state: "never_configured" }), actor, date); + assert.ok(closed.isSuccess); + assert.ok(mapApplicationInput(input, { ...context, configurations: [closed.data] }).isSuccess); +}); +test("aplicaciones: inactividad conserva notas y permite retirada, impide reconfigurar", () => { + const { input, context } = fixture(); + const root = create({ ...input, state: "configured" }); + const inactive = { + ...context, + configurations: [root], + resources: [{ id: resource, active: false, usage: "application" as const }], + }; + assert.equal(buildApplicationResponse(root, inactive).needsReview, true); + assert.ok( + mapApplicationInput({ ...root.values.toPrimitive(), notes: "Retirar" }, inactive, root) + .isSuccess + ); + assert.ok( + mapApplicationInput({ ...root.values.toPrimitive(), state: "removal_pending" }, inactive, root) + .isSuccess + ); + assert.equal( + mapApplicationInput({ ...root.values.toPrimitive(), version: "2" }, inactive, root).isSuccess, + false + ); + assert.equal( + mapApplicationInput({ ...root.values.toPrimitive(), delivery: "web" }, inactive, root) + .isSuccess, + false + ); + assert.equal( + mapApplicationInput(input, { ...context, people: [{ id: person, active: false }] }).isSuccess, + false + ); + assert.equal( + mapApplicationInput(input, { ...context, assets: [{ id: host, available: false }] }).isSuccess, + false + ); +}); +test("aplicaciones: persistencia preserva historial y rechaza claves y orden incoherentes", () => { + const { input } = fixture(); + const root = create(input); + const mapper = new ApplicationConfigurationPersistenceMapper(); + const record = mapper.toPersistence(root); + const rehydrated = mapper.toDomain(record.root, record.revisions); + assert.ok(rehydrated.id.equals(root.id)); + assert.ok(rehydrated.history[0]?.id.equals(root.history[0]?.id)); + assert.throws(() => mapper.toDomain({ ...record.root, currentKey: null }, record.revisions)); + assert.throws(() => + mapper.toDomain( + record.root, + record.revisions.map((r) => ({ ...r, position: 1 })) + ) + ); + assert.throws(() => + mapper.toDomain( + record.root, + record.revisions.map((r) => ({ ...r, configurationId: company.toPrimitive() })) + ) + ); +}); diff --git a/modules/companies/src/api/domain/certificate-configuration-revision.entity.ts b/modules/companies/src/api/domain/certificate-configuration-revision.entity.ts new file mode 100644 index 0000000..2a4a933 --- /dev/null +++ b/modules/companies/src/api/domain/certificate-configuration-revision.entity.ts @@ -0,0 +1,25 @@ +import { DomainEntity, type UniqueID } from "@repo/rdx-ddd"; + +import type { CertificateConfigurationValues } from "./value-objects/certificate-configuration-values"; +export type CertificateConfigurationRevisionProps = Readonly<{ + values: CertificateConfigurationValues; + actorId: UniqueID; + recordedAt: string; +}>; +export class CertificateConfigurationRevision extends DomainEntity { + static create(props: CertificateConfigurationRevisionProps) { + return new CertificateConfigurationRevision(Object.freeze({ ...props })); + } + static rehydrate(props: CertificateConfigurationRevisionProps, id: UniqueID) { + return new CertificateConfigurationRevision(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/certificate-configuration-values.ts b/modules/companies/src/api/domain/certificate-configuration-values.ts new file mode 100644 index 0000000..9ef1e3b --- /dev/null +++ b/modules/companies/src/api/domain/certificate-configuration-values.ts @@ -0,0 +1,23 @@ +import { z } from "zod/v4"; + +import { ConfigurationSchema } from "./access/access-definition"; +export const CertificateConfigurationInputSchema = z.strictObject({ + profileId: z.uuid(), + resourceId: z.uuid(), + location: z.string().trim().max(255).nullable(), + state: ConfigurationSchema, + notes: z.string().trim().max(4000).nullable(), +}); +export type CertificateConfigurationInput = z.infer; +export class CertificateConfigurationError extends Error { + constructor(public readonly code: string) { + super(code); + } +} +export const CERTIFICATE_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/certificate-configuration.aggregate.ts b/modules/companies/src/api/domain/certificate-configuration.aggregate.ts new file mode 100644 index 0000000..c73e407 --- /dev/null +++ b/modules/companies/src/api/domain/certificate-configuration.aggregate.ts @@ -0,0 +1,92 @@ +import { AggregateRoot, type UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import { z } from "zod/v4"; + +import { CertificateConfigurationRevision } from "./certificate-configuration-revision.entity"; +import { + CERTIFICATE_STATE_TRANSITIONS, + CertificateConfigurationError, +} from "./certificate-configuration-values"; +import type { CertificateConfigurationValues } from "./value-objects/certificate-configuration-values"; + +type Props = Readonly<{ + companyId: UniqueID; + values: CertificateConfigurationValues; + history: readonly CertificateConfigurationRevision[]; +}>; +export class CertificateConfiguration extends AggregateRoot { + private constructor(props: Props, id?: UniqueID) { + super(Object.freeze({ ...props, history: Object.freeze([...props.history]) }), id); + } + static create( + companyId: UniqueID, + values: CertificateConfigurationValues, + actorId: UniqueID, + recordedAt: string + ): Result { + if ( + !( + z.iso.datetime().safeParse(recordedAt).success && + ["pending", "configured"].includes(values.state) + ) + ) { + return Result.fail(new CertificateConfigurationError("INVALID_CERTIFICATE_TRANSITION")); + } + return Result.ok( + new CertificateConfiguration({ + companyId, + values, + history: [CertificateConfigurationRevision.create({ values, actorId, recordedAt })], + }) + ); + } + static rehydrate(props: Props, id: UniqueID) { + return new CertificateConfiguration(props, id); + } + get companyId() { + return this.props.companyId; + } + get values() { + return this.props.values; + } + get history() { + return this.props.history; + } + update( + values: CertificateConfigurationValues, + actorId: UniqueID, + recordedAt: string + ): Result { + if (this.values.equals(values)) { + return Result.ok(this); + } + if (!this.values.sameTarget(values)) { + return Result.fail(new CertificateConfigurationError("CERTIFICATE_TARGET_IMMUTABLE")); + } + if ( + !CERTIFICATE_STATE_TRANSITIONS[this.values.state].some((state) => state === values.state) || + (this.values.closed && !this.values.equals(values)) + ) { + return Result.fail(new CertificateConfigurationError("INVALID_CERTIFICATE_TRANSITION")); + } + if ( + !z.iso.datetime().safeParse(recordedAt).success || + recordedAt < (this.history.at(-1)?.recordedAt ?? "") + ) { + return Result.fail(new CertificateConfigurationError("INVALID_REQUEST")); + } + return Result.ok( + new CertificateConfiguration( + { + ...this.props, + values, + history: [ + ...this.history, + CertificateConfigurationRevision.create({ values, actorId, recordedAt }), + ], + }, + this.id + ) + ); + } +} diff --git a/modules/companies/src/api/domain/certificate-configuration.test.ts b/modules/companies/src/api/domain/certificate-configuration.test.ts new file mode 100644 index 0000000..a860067 --- /dev/null +++ b/modules/companies/src/api/domain/certificate-configuration.test.ts @@ -0,0 +1,261 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { UniqueID } from "@repo/rdx-ddd"; + +import type { CertificateContext } from "../application/certificate-configuration.repository"; +import { mapCertificateInput } from "../application/certificate-configuration-input.mapper"; +import { buildCertificateResponse } from "../application/certificate-configuration-response.builder"; +import { CertificateConfigurationPersistenceMapper } from "../infrastructure/persistence/certificate-configuration-persistence.mapper"; + +import { CertificateConfiguration } from "./certificate-configuration.aggregate"; +import { + type CertificateConfigurationInput, + CertificateConfigurationInputSchema, +} from "./certificate-configuration-values"; +import { CertificateConfigurationValues } from "./value-objects/certificate-configuration-values"; +import { WorkProfileValues } from "./value-objects/work-profile-values"; +import { WorkProfile } from "./work-profile.aggregate"; + +const company = UniqueID.generateNewID(); +const actor = UniqueID.generateNewID(); +const person = UniqueID.generateNewID(); +const host = UniqueID.generateNewID(); +const resource = UniqueID.generateNewID(); +const date = "2026-10-06T12:00:00.000Z"; +function fixture() { + const value = WorkProfileValues.create({ + name: "Ana", + personId: person.toPrimitive(), + assetId: host.toPrimitive(), + accountId: null, + active: true, + notes: null, + }); + assert.ok(value.isSuccess); + const profile = WorkProfile.create(company, value.data, actor, date); + assert.ok(profile.isSuccess); + const input: CertificateConfigurationInput = { + profileId: profile.data.id.toPrimitive(), + resourceId: resource.toPrimitive(), + location: null, + state: "pending", + notes: null, + }; + const context: CertificateContext = { + profiles: [profile.data], + today: "2026-10-06", + configurations: [], + people: [{ id: person, active: true }], + assets: [{ id: host, available: true }], + resources: [{ id: resource, active: true, usage: "certificate", expiresOn: null }], + actors: new Map([[actor.toPrimitive(), "Técnico"]]), + }; + return { input, context }; +} +function values(input: CertificateConfigurationInput) { + const value = CertificateConfigurationValues.create(input); + assert.ok(value.isSuccess); + return value.data; +} +function create(input: CertificateConfigurationInput) { + const root = CertificateConfiguration.create(company, values(input), actor, date); + assert.ok(root.isSuccess); + return root.data; +} +test("certificados: contrato estricto, ubicación opcional y límites", () => { + const { input } = fixture(); + assert.equal( + CertificateConfigurationInputSchema.safeParse({ ...input, actorId: actor.toPrimitive() }) + .success, + false + ); + assert.equal( + CertificateConfigurationInputSchema.safeParse({ ...input, privateKey: "secret" }).success, + false + ); + assert.equal(values({ ...input, location: " ", notes: " " }).toPrimitive().location, null); + assert.equal(values({ ...input, location: " Firefox " }).toPrimitive().location, "Firefox"); + assert.equal( + CertificateConfigurationValues.create({ ...input, location: "a".repeat(256) }).isSuccess, + false + ); + assert.equal( + CertificateConfigurationValues.create({ ...input, notes: "a".repeat(4001) }).isSuccess, + false + ); +}); +test("certificados: UUID, idempotencia, ubicación auditada, destino fijo y retirada", () => { + const { input } = fixture(); + const root = create(input); + assert.equal(root.id.toPrimitive()[14], "7"); + assert.equal(root.history[0]?.id.toPrimitive()[14], "7"); + const same = root.update(values({ ...input, notes: " " }), actor, date); + assert.ok(same.isSuccess); + assert.equal(same.data, root); + const configured = root.update( + values({ ...input, state: "configured", location: "Windows" }), + actor, + date + ); + assert.ok(configured.isSuccess); + assert.equal(configured.data.history.length, 2); + assert.equal(configured.data.history[0]?.values.location.getOrUndefined(), undefined); + assert.equal( + configured.data.update( + values({ ...input, resourceId: UniqueID.generateNewID().toPrimitive() }), + actor, + date + ).isSuccess, + false + ); + const removal = configured.data.update( + values({ ...configured.data.values.toPrimitive(), state: "removal_pending" }), + actor, + date + ); + assert.ok(removal.isSuccess); + const removed = removal.data.update( + values({ ...removal.data.values.toPrimitive(), state: "removed" }), + actor, + date + ); + assert.ok(removed.isSuccess); + assert.equal( + removed.data.update( + values({ ...removed.data.values.toPrimitive(), notes: "Otra" }), + actor, + date + ).isSuccess, + false + ); + assert.equal( + root.update(values({ ...input, state: "configured" }), actor, "2026-10-05T12:00:00.000Z") + .isSuccess, + false + ); + assert.equal( + CertificateConfiguration.create(company, values({ ...input, state: "removed" }), actor, date) + .isSuccess, + false + ); +}); +test("certificados: ámbito y clasificación explícita, duplicados abiertos y reapertura nueva", () => { + const { input, context } = fixture(); + const root = create(input); + const duplicate = mapCertificateInput(input, { ...context, configurations: [root] }); + assert.ok(!duplicate.isSuccess); + assert.equal(duplicate.error.code, "CERTIFICATE_ALREADY_OPEN"); + assert.equal( + mapCertificateInput({ ...input, resourceId: host.toPrimitive() }, context).isSuccess, + false + ); + assert.equal( + mapCertificateInput(input, { + ...context, + resources: [{ id: resource, active: true, usage: "application", expiresOn: null }], + }).isSuccess, + false + ); + assert.equal( + mapCertificateInput(input, { + ...context, + resources: [{ id: resource, active: true, usage: null, expiresOn: null }], + }).isSuccess, + false + ); + const closed = root.update(values({ ...input, state: "never_configured" }), actor, date); + assert.ok(closed.isSuccess); + assert.ok(mapCertificateInput(input, { ...context, configurations: [closed.data] }).isSuccess); +}); +test("certificados: inactividad conserva notas y permite retirada, impide reconfigurar", () => { + const { input, context } = fixture(); + const root = create({ ...input, state: "configured" }); + const inactive = { + ...context, + configurations: [root], + resources: [{ id: resource, active: false, usage: "certificate" as const, expiresOn: null }], + }; + assert.equal(buildCertificateResponse(root, inactive).needsReview, true); + assert.ok( + mapCertificateInput({ ...root.values.toPrimitive(), notes: "Retirar" }, inactive, root) + .isSuccess + ); + assert.ok( + mapCertificateInput({ ...root.values.toPrimitive(), state: "removal_pending" }, inactive, root) + .isSuccess + ); + assert.equal( + mapCertificateInput({ ...root.values.toPrimitive(), location: "Firefox" }, inactive, root) + .isSuccess, + false + ); + assert.equal( + mapCertificateInput(input, { ...context, people: [{ id: person, active: false }] }).isSuccess, + false + ); + assert.equal( + mapCertificateInput(input, { ...context, assets: [{ id: host, available: false }] }).isSuccess, + false + ); +}); +test("certificados: persistencia preserva historial y rechaza claves y orden incoherentes", () => { + const { input } = fixture(); + const root = create(input); + const mapper = new CertificateConfigurationPersistenceMapper(); + const record = mapper.toPersistence(root); + const rehydrated = mapper.toDomain(record.root, record.revisions); + assert.ok(rehydrated.id.equals(root.id)); + assert.ok(rehydrated.history[0]?.id.equals(root.history[0]?.id)); + assert.throws(() => mapper.toDomain({ ...record.root, currentKey: null }, record.revisions)); + assert.throws(() => + mapper.toDomain( + record.root, + record.revisions.map((r) => ({ ...r, position: 1 })) + ) + ); + assert.throws(() => + mapper.toDomain( + record.root, + record.revisions.map((r) => ({ ...r, configurationId: company.toPrimitive() })) + ) + ); +}); + +test("certificados: caducidad inclusiva, revisión y retirada sin perder historial", () => { + const { input, context } = fixture(); + const root = create({ ...input, state: "configured" }); + const expired = { + ...context, + configurations: [root], + resources: [ + { id: resource, active: true, usage: "certificate" as const, expiresOn: "2026-10-05" }, + ], + }; + assert.equal(buildCertificateResponse(root, expired).needsReview, true); + assert.equal(mapCertificateInput(input, expired).isSuccess, false); + assert.ok( + mapCertificateInput( + { ...root.values.toPrimitive(), notes: "Retirar certificado caducado" }, + expired, + root + ).isSuccess + ); + assert.ok( + mapCertificateInput({ ...root.values.toPrimitive(), state: "removal_pending" }, expired, root) + .isSuccess + ); + assert.equal( + mapCertificateInput({ ...root.values.toPrimitive(), location: "Firefox" }, expired, root) + .isSuccess, + false + ); + const today = { + ...context, + resources: [ + { id: resource, active: true, usage: "certificate" as const, expiresOn: context.today }, + ], + }; + assert.ok(mapCertificateInput(input, today).isSuccess); + assert.equal(buildCertificateResponse(root, today).needsReview, false); +}); diff --git a/modules/companies/src/api/domain/value-objects/application-configuration-values.ts b/modules/companies/src/api/domain/value-objects/application-configuration-values.ts new file mode 100644 index 0000000..b2dde33 --- /dev/null +++ b/modules/companies/src/api/domain/value-objects/application-configuration-values.ts @@ -0,0 +1,69 @@ +import { UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { + ApplicationConfigurationError, + type ApplicationConfigurationInput, + ApplicationConfigurationInputSchema, +} from "../application-configuration-values"; +export class ApplicationConfigurationValues { + private constructor( + public readonly profileId: UniqueID, + public readonly resourceId: UniqueID, + public readonly delivery: ApplicationConfigurationInput["delivery"], + public readonly version: Maybe, + public readonly state: ApplicationConfigurationInput["state"], + public readonly notes: Maybe + ) { + Object.freeze(this); + } + static create( + input: ApplicationConfigurationInput + ): Result { + const parsed = ApplicationConfigurationInputSchema.safeParse(input); + if (!parsed.success) { + return Result.fail(new ApplicationConfigurationError("INVALID_REQUEST")); + } + const data = parsed.data; + const profile = UniqueID.create(data.profileId); + const resource = UniqueID.create(data.resourceId); + if (!(profile.isSuccess && resource.isSuccess) || (data.delivery === "web" && data.version)) { + return Result.fail(new ApplicationConfigurationError("INVALID_REQUEST")); + } + return Result.ok( + new ApplicationConfigurationValues( + profile.data, + resource.data, + data.delivery, + 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: ApplicationConfigurationValues) { + return this.profileId.equals(other.profileId) && this.resourceId.equals(other.resourceId); + } + equals(other: ApplicationConfigurationValues) { + return ( + this.sameTarget(other) && + this.delivery === other.delivery && + this.state === other.state && + this.version.getOrUndefined() === other.version.getOrUndefined() && + this.notes.getOrUndefined() === other.notes.getOrUndefined() + ); + } + toPrimitive(): ApplicationConfigurationInput { + return { + profileId: this.profileId.toPrimitive(), + resourceId: this.resourceId.toPrimitive(), + delivery: this.delivery, + version: this.version.getOrUndefined() ?? null, + state: this.state, + notes: this.notes.getOrUndefined() ?? null, + }; + } +} diff --git a/modules/companies/src/api/domain/value-objects/certificate-configuration-values.ts b/modules/companies/src/api/domain/value-objects/certificate-configuration-values.ts new file mode 100644 index 0000000..80c33e3 --- /dev/null +++ b/modules/companies/src/api/domain/value-objects/certificate-configuration-values.ts @@ -0,0 +1,65 @@ +import { UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { + CertificateConfigurationError, + type CertificateConfigurationInput, + CertificateConfigurationInputSchema, +} from "../certificate-configuration-values"; +export class CertificateConfigurationValues { + private constructor( + public readonly profileId: UniqueID, + public readonly resourceId: UniqueID, + public readonly location: Maybe, + public readonly state: CertificateConfigurationInput["state"], + public readonly notes: Maybe + ) { + Object.freeze(this); + } + static create( + input: CertificateConfigurationInput + ): Result { + const parsed = CertificateConfigurationInputSchema.safeParse(input); + if (!parsed.success) { + return Result.fail(new CertificateConfigurationError("INVALID_REQUEST")); + } + const data = parsed.data; + const profile = UniqueID.create(data.profileId); + const resource = UniqueID.create(data.resourceId); + if (!(profile.isSuccess && resource.isSuccess)) { + return Result.fail(new CertificateConfigurationError("INVALID_REQUEST")); + } + return Result.ok( + new CertificateConfigurationValues( + profile.data, + resource.data, + data.location ? Maybe.some(data.location) : Maybe.none(), + data.state, + data.notes ? Maybe.some(data.notes) : Maybe.none() + ) + ); + } + get closed() { + return this.state === "removed" || this.state === "never_configured"; + } + sameTarget(other: CertificateConfigurationValues) { + return this.profileId.equals(other.profileId) && this.resourceId.equals(other.resourceId); + } + equals(other: CertificateConfigurationValues) { + return ( + this.sameTarget(other) && + this.location.getOrUndefined() === other.location.getOrUndefined() && + this.state === other.state && + this.notes.getOrUndefined() === other.notes.getOrUndefined() + ); + } + toPrimitive(): CertificateConfigurationInput { + return { + profileId: this.profileId.toPrimitive(), + resourceId: this.resourceId.toPrimitive(), + location: this.location.getOrUndefined() ?? null, + state: this.state, + notes: this.notes.getOrUndefined() ?? null, + }; + } +} diff --git a/modules/companies/src/api/infrastructure/http/application-configuration.routes.ts b/modules/companies/src/api/infrastructure/http/application-configuration.routes.ts new file mode 100644 index 0000000..de2bdf5 --- /dev/null +++ b/modules/companies/src/api/infrastructure/http/application-configuration.routes.ts @@ -0,0 +1,81 @@ +import { type Application, type RequestHandler, type Response, Router } from "express"; +import { z } from "zod/v4"; + +import { + ApplicationItemParamsSchema, + ApplicationParamsSchema, + SaveApplicationConfigurationRequestSchema, +} from "../../../common/application-configurations"; +import type { ApplicationConfigurationUseCases } from "../../application/application-configuration.use-cases"; +import { buildApplicationResponse } from "../../application/application-configuration-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 registerApplicationConfigurationRoutes( + app: Application, + useCases: ApplicationConfigurationUseCases, + authenticate: RequestHandler +) { + const router = Router(); + router.get("/:companyId", (request, response, next) => { + const params = ApplicationParamsSchema.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) => + buildApplicationResponse(item, result.data) + ), + }); + }) + .catch(next); + }); + for (const path of ["/:companyId", "/:companyId/:id/update"]) { + router.post(path, (request, response, next) => { + const params = ( + path.endsWith("update") ? ApplicationItemParamsSchema : ApplicationParamsSchema + ).safeParse(request.params); + const body = SaveApplicationConfigurationRequestSchema.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: buildApplicationResponse(result.data.configuration, result.data.context), + }); + }) + .catch(next); + }); + } + app.use("/api/application-configurations", authenticate, router); +} diff --git a/modules/companies/src/api/infrastructure/http/certificate-configuration.routes.ts b/modules/companies/src/api/infrastructure/http/certificate-configuration.routes.ts new file mode 100644 index 0000000..f46c12d --- /dev/null +++ b/modules/companies/src/api/infrastructure/http/certificate-configuration.routes.ts @@ -0,0 +1,81 @@ +import { type Application, type RequestHandler, type Response, Router } from "express"; +import { z } from "zod/v4"; + +import { + CertificateItemParamsSchema, + CertificateParamsSchema, + SaveCertificateConfigurationRequestSchema, +} from "../../../common/certificate-configurations"; +import type { CertificateConfigurationUseCases } from "../../application/certificate-configuration.use-cases"; +import { buildCertificateResponse } from "../../application/certificate-configuration-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 registerCertificateConfigurationRoutes( + app: Application, + useCases: CertificateConfigurationUseCases, + authenticate: RequestHandler +) { + const router = Router(); + router.get("/:companyId", (request, response, next) => { + const params = CertificateParamsSchema.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) => + buildCertificateResponse(item, result.data) + ), + }); + }) + .catch(next); + }); + for (const path of ["/:companyId", "/:companyId/:id/update"]) { + router.post(path, (request, response, next) => { + const params = ( + path.endsWith("update") ? CertificateItemParamsSchema : CertificateParamsSchema + ).safeParse(request.params); + const body = SaveCertificateConfigurationRequestSchema.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: buildCertificateResponse(result.data.configuration, result.data.context), + }); + }) + .catch(next); + }); + } + app.use("/api/certificate-configurations", authenticate, router); +} diff --git a/modules/companies/src/api/infrastructure/persistence/application-configuration-migration.ts b/modules/companies/src/api/infrastructure/persistence/application-configuration-migration.ts new file mode 100644 index 0000000..39cb6e0 --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/application-configuration-migration.ts @@ -0,0 +1,44 @@ +import { DataTypes, QueryTypes, type Sequelize } from "sequelize"; +export async function migrateApplicationConfigurations(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 === "application_configurations")) { + await q.createTable("application_configurations", { + id: { type: DataTypes.UUID, primaryKey: true }, + companyId: fk("companies"), + profileId: fk("work_profiles"), + resourceId: fk("resources"), + values: { type: DataTypes.JSON, allowNull: false }, + currentKey: { type: DataTypes.STRING(80), allowNull: true, unique: true }, + }); + } + if (!tables.some((t) => t.name === "application_configuration_revisions")) { + await q.createTable("application_configuration_revisions", { + id: { type: DataTypes.UUID, primaryKey: true }, + configurationId: fk("application_configurations"), + position: { type: DataTypes.INTEGER, allowNull: false }, + values: { type: DataTypes.JSON, allowNull: false }, + 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='application_configuration_revisions'", + { type: QueryTypes.SELECT } + ); + if (!indexes.some((item) => item.name === "application_configuration_position")) { + await q.addIndex("application_configuration_revisions", ["configurationId", "position"], { + unique: true, + name: "application_configuration_position", + }); + } +} diff --git a/modules/companies/src/api/infrastructure/persistence/application-configuration-persistence.mapper.ts b/modules/companies/src/api/infrastructure/persistence/application-configuration-persistence.mapper.ts new file mode 100644 index 0000000..b45b49e --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/application-configuration-persistence.mapper.ts @@ -0,0 +1,119 @@ +import { z } from "zod/v4"; + +import { ApplicationConfiguration } from "../../domain/application-configuration.aggregate"; +import { ApplicationConfigurationRevision } from "../../domain/application-configuration-revision.entity"; +import { + APPLICATION_STATE_TRANSITIONS, + ApplicationConfigurationInputSchema, +} from "../../domain/application-configuration-values"; +import { ApplicationConfigurationValues } from "../../domain/value-objects/application-configuration-values"; + +import { persistedJson, profileIdentity } from "./work-profile-persistence.mapper"; +export type ApplicationRow = { + id: string; + companyId: string; + profileId: string; + resourceId: string; + values: unknown; + currentKey: string | null; +}; +export type ApplicationRevisionRow = { + id: string; + configurationId: string; + position: number; + values: unknown; + actorId: string; + recordedAt: string; +}; +function mapped(raw: unknown) { + const parsed = ApplicationConfigurationValues.create( + ApplicationConfigurationInputSchema.parse(persistedJson(raw)) + ); + if (!parsed.isSuccess) { + throw new Error("Valores de aplicación persistidos inconsistentes."); + } + return parsed.data; +} +export class ApplicationConfigurationPersistenceMapper { + toDomain(row: ApplicationRow, revisions: readonly ApplicationRevisionRow[]) { + const values = mapped(row.values); + const ordered = [...revisions].sort((a, b) => a.position - b.position); + const history = ordered.map((revision, index) => { + const value = mapped(revision.values); + 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 aplicación persistido inconsistente."); + } + return ApplicationConfigurationRevision.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 aplicación persistido inconsistente."); + } + for (const [index, revision] of history.entries()) { + const previous = history[index - 1]?.values; + if ( + previous && + (!APPLICATION_STATE_TRANSITIONS[previous.state].some( + (state) => state === revision.values.state + ) || + (previous.closed && !previous.equals(revision.values))) + ) { + throw new Error("Estados de aplicación históricos inconsistentes."); + } + } + const keys = applicationKeys(values); + if ( + !history.at(-1)?.values.equals(values) || + row.profileId !== values.profileId.toPrimitive() || + row.resourceId !== values.resourceId.toPrimitive() || + row.currentKey !== keys.currentKey + ) { + throw new Error("Referencias de aplicación persistidas inconsistentes."); + } + return ApplicationConfiguration.rehydrate( + { companyId: profileIdentity(row.companyId), values, history }, + profileIdentity(row.id) + ); + } + toPersistence(item: ApplicationConfiguration) { + const values = item.values.toPrimitive(); + return { + root: { + id: item.id.toPrimitive(), + companyId: item.companyId.toPrimitive(), + profileId: values.profileId, + resourceId: values.resourceId, + values: JSON.stringify(values), + ...applicationKeys(item.values), + }, + revisions: item.history.map((revision, position) => ({ + id: revision.id.toPrimitive(), + configurationId: item.id.toPrimitive(), + position, + actorId: revision.actorId.toPrimitive(), + recordedAt: revision.recordedAt, + values: JSON.stringify(revision.values.toPrimitive()), + })), + }; + } +} +function applicationKeys(values: ApplicationConfigurationValues) { + return { + currentKey: values.closed + ? null + : `${values.profileId.toPrimitive()}:${values.resourceId.toPrimitive()}`, + }; +} diff --git a/modules/companies/src/api/infrastructure/persistence/certificate-configuration-migration.ts b/modules/companies/src/api/infrastructure/persistence/certificate-configuration-migration.ts new file mode 100644 index 0000000..b56d710 --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/certificate-configuration-migration.ts @@ -0,0 +1,44 @@ +import { DataTypes, QueryTypes, type Sequelize } from "sequelize"; +export async function migrateCertificateConfigurations(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 === "certificate_configurations")) { + await q.createTable("certificate_configurations", { + id: { type: DataTypes.UUID, primaryKey: true }, + companyId: fk("companies"), + profileId: fk("work_profiles"), + resourceId: fk("resources"), + values: { type: DataTypes.JSON, allowNull: false }, + currentKey: { type: DataTypes.STRING(80), allowNull: true, unique: true }, + }); + } + if (!tables.some((t) => t.name === "certificate_configuration_revisions")) { + await q.createTable("certificate_configuration_revisions", { + id: { type: DataTypes.UUID, primaryKey: true }, + configurationId: fk("certificate_configurations"), + position: { type: DataTypes.INTEGER, allowNull: false }, + values: { type: DataTypes.JSON, allowNull: false }, + 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='certificate_configuration_revisions'", + { type: QueryTypes.SELECT } + ); + if (!indexes.some((item) => item.name === "certificate_configuration_position")) { + await q.addIndex("certificate_configuration_revisions", ["configurationId", "position"], { + unique: true, + name: "certificate_configuration_position", + }); + } +} diff --git a/modules/companies/src/api/infrastructure/persistence/certificate-configuration-persistence.mapper.ts b/modules/companies/src/api/infrastructure/persistence/certificate-configuration-persistence.mapper.ts new file mode 100644 index 0000000..641672e --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/certificate-configuration-persistence.mapper.ts @@ -0,0 +1,119 @@ +import { z } from "zod/v4"; + +import { CertificateConfiguration } from "../../domain/certificate-configuration.aggregate"; +import { CertificateConfigurationRevision } from "../../domain/certificate-configuration-revision.entity"; +import { + CERTIFICATE_STATE_TRANSITIONS, + CertificateConfigurationInputSchema, +} from "../../domain/certificate-configuration-values"; +import { CertificateConfigurationValues } from "../../domain/value-objects/certificate-configuration-values"; + +import { persistedJson, profileIdentity } from "./work-profile-persistence.mapper"; +export type CertificateRow = { + id: string; + companyId: string; + profileId: string; + resourceId: string; + values: unknown; + currentKey: string | null; +}; +export type CertificateRevisionRow = { + id: string; + configurationId: string; + position: number; + values: unknown; + actorId: string; + recordedAt: string; +}; +function mapped(raw: unknown) { + const parsed = CertificateConfigurationValues.create( + CertificateConfigurationInputSchema.parse(persistedJson(raw)) + ); + if (!parsed.isSuccess) { + throw new Error("Valores de certificado persistidos inconsistentes."); + } + return parsed.data; +} +export class CertificateConfigurationPersistenceMapper { + toDomain(row: CertificateRow, revisions: readonly CertificateRevisionRow[]) { + const values = mapped(row.values); + const ordered = [...revisions].sort((a, b) => a.position - b.position); + const history = ordered.map((revision, index) => { + const value = mapped(revision.values); + 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 certificado persistido inconsistente."); + } + return CertificateConfigurationRevision.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 certificado persistido inconsistente."); + } + for (const [index, revision] of history.entries()) { + const previous = history[index - 1]?.values; + if ( + previous && + (!CERTIFICATE_STATE_TRANSITIONS[previous.state].some( + (state) => state === revision.values.state + ) || + (previous.closed && !previous.equals(revision.values))) + ) { + throw new Error("Estados de certificado históricos inconsistentes."); + } + } + const keys = certificateKeys(values); + if ( + !history.at(-1)?.values.equals(values) || + row.profileId !== values.profileId.toPrimitive() || + row.resourceId !== values.resourceId.toPrimitive() || + row.currentKey !== keys.currentKey + ) { + throw new Error("Referencias de certificado persistidas inconsistentes."); + } + return CertificateConfiguration.rehydrate( + { companyId: profileIdentity(row.companyId), values, history }, + profileIdentity(row.id) + ); + } + toPersistence(item: CertificateConfiguration) { + const values = item.values.toPrimitive(); + return { + root: { + id: item.id.toPrimitive(), + companyId: item.companyId.toPrimitive(), + profileId: values.profileId, + resourceId: values.resourceId, + values: JSON.stringify(values), + ...certificateKeys(item.values), + }, + revisions: item.history.map((revision, position) => ({ + id: revision.id.toPrimitive(), + configurationId: item.id.toPrimitive(), + position, + actorId: revision.actorId.toPrimitive(), + recordedAt: revision.recordedAt, + values: JSON.stringify(revision.values.toPrimitive()), + })), + }; + } +} +function certificateKeys(values: CertificateConfigurationValues) { + return { + currentKey: values.closed + ? null + : `${values.profileId.toPrimitive()}:${values.resourceId.toPrimitive()}`, + }; +} diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize-application-configuration.repository.ts b/modules/companies/src/api/infrastructure/persistence/sequelize-application-configuration.repository.ts new file mode 100644 index 0000000..456b7ae --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/sequelize-application-configuration.repository.ts @@ -0,0 +1,138 @@ +import type { IResourceInventoryService } from "@metis/resources/services"; +import type { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import { QueryTypes, type Sequelize, type Transaction } from "sequelize"; + +import type { + ApplicationContext, + IApplicationConfigurationRepository, +} from "../../application/application-configuration.repository"; +import { applicationReferences } from "../../application/application-configuration-policy"; +import type { IWorkProfileInventoryService } from "../../application/work-profile.repository"; +import { ApplicationConfigurationError } from "../../domain/application-configuration-values"; + +import { + ApplicationConfigurationPersistenceMapper, + type ApplicationRevisionRow, + type ApplicationRow, +} from "./application-configuration-persistence.mapper"; +import { + type ProfileRevisionRow, + type ProfileRow, + WorkProfilePersistenceMapper, + profileIdentity, +} from "./work-profile-persistence.mapper"; +export class SequelizeApplicationConfigurationRepository + implements IApplicationConfigurationRepository +{ + private readonly mapper = new ApplicationConfigurationPersistenceMapper(); + 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 ApplicationConfigurationError("COMPANY_NOT_FOUND")); + } + const rows = await this.database.query( + "SELECT * FROM application_configurations WHERE companyId=:companyId", + { ...options, type: QueryTypes.SELECT } + ); + const revisions = await this.database.query( + "SELECT r.* FROM application_configuration_revisions r JOIN application_configurations m ON m.id=r.configurationId WHERE m.companyId=:companyId", + { ...options, type: QueryTypes.SELECT } + ); + const profiles = await this.database.query( + "SELECT * FROM work_profiles WHERE companyId=:companyId", + { ...options, type: QueryTypes.SELECT } + ); + const profileRevisions = await this.database.query( + "SELECT r.* FROM work_profile_revisions r JOIN work_profiles p ON p.id=r.profileId WHERE p.companyId=:companyId", + { ...options, type: QueryTypes.SELECT } + ); + const people = await this.database.query<{ id: string; active: boolean }>( + "SELECT id,active FROM company_contacts WHERE 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: ApplicationContext = { + configurations: rows.map((row) => + this.mapper.toDomain( + row, + revisions.filter((r) => r.configurationId === row.id) + ) + ), + profiles: profiles.map((row) => + new WorkProfilePersistenceMapper().toDomain( + row, + profileRevisions.filter((r) => r.profileId === row.id) + ) + ), + people: people.map((item) => ({ + id: profileIdentity(item.id), + active: Boolean(item.active), + })), + assets: await this.inventory.list(company), + resources: await this.resources.list(company), + actors: new Map(actors.map((actor) => [actor.id, actor.name])), + }; + if ( + context.profiles.some( + (profile) => + !( + context.people.some((person) => person.id.equals(profile.values.personId)) && + context.assets.some((asset) => asset.id.equals(profile.values.assetId)) + ) + ) || + context.configurations.some((mapping) => !applicationReferences(mapping.values, context)) + ) { + throw new Error("Ámbito de aplicación persistido inconsistente."); + } + return Result.ok(context); + } + get(company: UniqueID) { + return this.database.transaction((t) => this.load(company, t, false)); + } + 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 application_configurations (id,companyId,profileId,resourceId,`values`,currentKey) VALUES (:id,:companyId,:profileId,:resourceId,:values,:currentKey) ON DUPLICATE KEY UPDATE `values`=VALUES(`values`),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 application_configuration_revisions (id,configurationId,position,actorId,recordedAt,`values`) VALUES (:id,:configurationId,:position,:actorId,:recordedAt,:values)", + { replacements: revision, transaction } + ); + } + } + return Result.ok({ configuration: candidate.data, context: loaded.data }); + }); + } +} diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize-certificate-configuration.repository.ts b/modules/companies/src/api/infrastructure/persistence/sequelize-certificate-configuration.repository.ts new file mode 100644 index 0000000..d9a3baa --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/sequelize-certificate-configuration.repository.ts @@ -0,0 +1,139 @@ +import type { IResourceInventoryService } from "@metis/resources/services"; +import type { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import { QueryTypes, type Sequelize, type Transaction } from "sequelize"; + +import type { + CertificateContext, + ICertificateConfigurationRepository, +} from "../../application/certificate-configuration.repository"; +import { certificateReferences } from "../../application/certificate-configuration-policy"; +import type { IWorkProfileInventoryService } from "../../application/work-profile.repository"; +import { CertificateConfigurationError } from "../../domain/certificate-configuration-values"; + +import { + CertificateConfigurationPersistenceMapper, + type CertificateRevisionRow, + type CertificateRow, +} from "./certificate-configuration-persistence.mapper"; +import { + type ProfileRevisionRow, + type ProfileRow, + WorkProfilePersistenceMapper, + profileIdentity, +} from "./work-profile-persistence.mapper"; +export class SequelizeCertificateConfigurationRepository + implements ICertificateConfigurationRepository +{ + private readonly mapper = new CertificateConfigurationPersistenceMapper(); + 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 CertificateConfigurationError("COMPANY_NOT_FOUND")); + } + const rows = await this.database.query( + "SELECT * FROM certificate_configurations WHERE companyId=:companyId", + { ...options, type: QueryTypes.SELECT } + ); + const revisions = await this.database.query( + "SELECT r.* FROM certificate_configuration_revisions r JOIN certificate_configurations m ON m.id=r.configurationId WHERE m.companyId=:companyId", + { ...options, type: QueryTypes.SELECT } + ); + const profiles = await this.database.query( + "SELECT * FROM work_profiles WHERE companyId=:companyId", + { ...options, type: QueryTypes.SELECT } + ); + const profileRevisions = await this.database.query( + "SELECT r.* FROM work_profile_revisions r JOIN work_profiles p ON p.id=r.profileId WHERE p.companyId=:companyId", + { ...options, type: QueryTypes.SELECT } + ); + const people = await this.database.query<{ id: string; active: boolean }>( + "SELECT id,active FROM company_contacts WHERE 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: CertificateContext = { + today: new Date().toISOString().slice(0, 10), + configurations: rows.map((row) => + this.mapper.toDomain( + row, + revisions.filter((r) => r.configurationId === row.id) + ) + ), + profiles: profiles.map((row) => + new WorkProfilePersistenceMapper().toDomain( + row, + profileRevisions.filter((r) => r.profileId === row.id) + ) + ), + people: people.map((item) => ({ + id: profileIdentity(item.id), + active: Boolean(item.active), + })), + assets: await this.inventory.list(company), + resources: await this.resources.list(company), + actors: new Map(actors.map((actor) => [actor.id, actor.name])), + }; + if ( + context.profiles.some( + (profile) => + !( + context.people.some((person) => person.id.equals(profile.values.personId)) && + context.assets.some((asset) => asset.id.equals(profile.values.assetId)) + ) + ) || + context.configurations.some((mapping) => !certificateReferences(mapping.values, context)) + ) { + throw new Error("Ámbito de certificado persistido inconsistente."); + } + return Result.ok(context); + } + get(company: UniqueID) { + return this.database.transaction((t) => this.load(company, t, false)); + } + 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 certificate_configurations (id,companyId,profileId,resourceId,`values`,currentKey) VALUES (:id,:companyId,:profileId,:resourceId,:values,:currentKey) ON DUPLICATE KEY UPDATE `values`=VALUES(`values`),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 certificate_configuration_revisions (id,configurationId,position,actorId,recordedAt,`values`) VALUES (:id,:configurationId,:position,:actorId,:recordedAt,:values)", + { replacements: revision, transaction } + ); + } + } + return Result.ok({ configuration: candidate.data, context: loaded.data }); + }); + } +} diff --git a/modules/companies/src/api/module.ts b/modules/companies/src/api/module.ts index 6aeac45..4cc383c 100644 --- a/modules/companies/src/api/module.ts +++ b/modules/companies/src/api/module.ts @@ -1,7 +1,10 @@ +import type { IResourceInventoryService } from "@metis/resources/services"; import type { RequestHandler } from "express"; import type { Sequelize } from "sequelize"; import { AccessUseCases } from "./application/access.use-cases"; +import { ApplicationConfigurationUseCases } from "./application/application-configuration.use-cases"; +import { CertificateConfigurationUseCases } from "./application/certificate-configuration.use-cases"; import { CompanyUseCases } from "./application/company.use-cases"; import { CompanyInputMapper } from "./application/company-input.mapper"; import { FolderMappingUseCases } from "./application/folder-mapping.use-cases"; @@ -12,6 +15,8 @@ import { PrinterConfigurationUseCases } from "./application/printer-configuratio import type { IWorkProfileInventoryService } from "./application/work-profile.repository"; import { WorkProfileUseCases } from "./application/work-profile.use-cases"; 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 { registerFolderMappingRoutes } from "./infrastructure/http/folder-mapping.routes"; import { registerMailRoutes } from "./infrastructure/http/mail-configuration.routes"; @@ -23,6 +28,8 @@ import { SequelizeAccessRepository, migrateAccess, } from "./infrastructure/persistence/access-persistence"; +import { migrateApplicationConfigurations } from "./infrastructure/persistence/application-configuration-migration"; +import { migrateCertificateConfigurations } from "./infrastructure/persistence/certificate-configuration-migration"; import { initializeCompanyModels } from "./infrastructure/persistence/company.model"; import { CompanyPersistenceMapper } from "./infrastructure/persistence/company-persistence.mapper"; import { migrateFolderMappings } from "./infrastructure/persistence/folder-mapping-migration"; @@ -32,6 +39,8 @@ import { migrateCompanies } from "./infrastructure/persistence/migrate-companies import { migratePeople } from "./infrastructure/persistence/migrate-people"; import { initializePeopleModels } from "./infrastructure/persistence/people.model"; import { migratePrinterConfigurations } from "./infrastructure/persistence/printer-configuration-migration"; +import { SequelizeApplicationConfigurationRepository } from "./infrastructure/persistence/sequelize-application-configuration.repository"; +import { SequelizeCertificateConfigurationRepository } from "./infrastructure/persistence/sequelize-certificate-configuration.repository"; import { SequelizeCompanyRepository } from "./infrastructure/persistence/sequelize-company.repository"; import { SequelizeFolderMappingRepository } from "./infrastructure/persistence/sequelize-folder-mapping.repository"; import { SequelizeMailConfigurationRepository } from "./infrastructure/persistence/sequelize-mail-configuration.repository"; @@ -56,10 +65,21 @@ export async function createCompanyModule( ); const useCases = new CompanyUseCases(repository, new CompanyInputMapper()); return { - initializeProfiles: async (inventory: IWorkProfileInventoryService) => { + initializeProfiles: async ( + inventory: IWorkProfileInventoryService, + resources: IResourceInventoryService + ) => { await migrateWorkProfiles(database); await migrateFolderMappings(database); await migratePrinterConfigurations(database); + await migrateApplicationConfigurations(database); + await migrateCertificateConfigurations(database); + const certificates = new CertificateConfigurationUseCases( + new SequelizeCertificateConfigurationRepository(database, inventory, resources) + ); + const applications = new ApplicationConfigurationUseCases( + new SequelizeApplicationConfigurationRepository(database, inventory, resources) + ); const printers = new PrinterConfigurationUseCases( new SequelizePrinterConfigurationRepository(database, inventory) ); @@ -74,6 +94,8 @@ export async function createCompanyModule( registerWorkProfileRoutes(app, profiles, authenticate); registerFolderMappingRoutes(app, folders, authenticate); registerPrinterConfigurationRoutes(app, printers, authenticate); + registerApplicationConfigurationRoutes(app, applications, authenticate); + registerCertificateConfigurationRoutes(app, certificates, authenticate); }, }; }, diff --git a/modules/companies/src/common/application-configurations.ts b/modules/companies/src/common/application-configurations.ts new file mode 100644 index 0000000..78ea08a --- /dev/null +++ b/modules/companies/src/common/application-configurations.ts @@ -0,0 +1,38 @@ +import { z } from "zod/v4"; + +import { ApplicationConfigurationInputSchema } from "../api/domain/application-configuration-values"; + +export type { ApplicationConfigurationInput } from "../api/domain/application-configuration-values"; +export { + APPLICATION_STATE_TRANSITIONS, + ApplicationConfigurationInputSchema, +} from "../api/domain/application-configuration-values"; +export const ApplicationParamsSchema = z.strictObject({ companyId: z.uuid() }); +export const ApplicationItemParamsSchema = ApplicationParamsSchema.extend({ id: z.uuid() }); +export const SaveApplicationConfigurationRequestSchema = z.strictObject({ + values: ApplicationConfigurationInputSchema, +}); +export const ApplicationConfigurationSchema = z.strictObject({ + id: z.uuid(), + companyId: z.uuid(), + values: ApplicationConfigurationInputSchema, + needsReview: z.boolean(), + history: z + .array( + z.strictObject({ + id: z.uuid(), + actorId: z.uuid(), + actorName: z.string().min(1), + recordedAt: z.iso.datetime(), + values: ApplicationConfigurationInputSchema, + }) + ) + .min(1), +}); +export const ApplicationConfigurationsResponseSchema = z.strictObject({ + configurations: z.array(ApplicationConfigurationSchema), +}); +export const ApplicationConfigurationResponseSchema = z.strictObject({ + configuration: ApplicationConfigurationSchema, +}); +export type ApplicationConfigurationDTO = z.infer; diff --git a/modules/companies/src/common/certificate-configurations.ts b/modules/companies/src/common/certificate-configurations.ts new file mode 100644 index 0000000..8677a22 --- /dev/null +++ b/modules/companies/src/common/certificate-configurations.ts @@ -0,0 +1,40 @@ +import { z } from "zod/v4"; + +import { CertificateConfigurationInputSchema } from "../api/domain/certificate-configuration-values"; + +export type { CertificateConfigurationInput } from "../api/domain/certificate-configuration-values"; +export { + CERTIFICATE_STATE_TRANSITIONS, + CertificateConfigurationInputSchema, +} from "../api/domain/certificate-configuration-values"; +export const CertificateParamsSchema = z.strictObject({ companyId: z.uuid() }); +export const CertificateItemParamsSchema = CertificateParamsSchema.extend({ id: z.uuid() }); +export const SaveCertificateConfigurationRequestSchema = z.strictObject({ + values: CertificateConfigurationInputSchema, +}); +export const CertificateConfigurationSchema = z.strictObject({ + id: z.uuid(), + companyId: z.uuid(), + values: CertificateConfigurationInputSchema, + needsReview: z.boolean(), + expiresOn: z.iso.date().nullable(), + expired: z.boolean(), + history: z + .array( + z.strictObject({ + id: z.uuid(), + actorId: z.uuid(), + actorName: z.string().min(1), + recordedAt: z.iso.datetime(), + values: CertificateConfigurationInputSchema, + }) + ) + .min(1), +}); +export const CertificateConfigurationsResponseSchema = z.strictObject({ + configurations: z.array(CertificateConfigurationSchema), +}); +export const CertificateConfigurationResponseSchema = z.strictObject({ + configuration: CertificateConfigurationSchema, +}); +export type CertificateConfigurationDTO = z.infer; diff --git a/modules/companies/src/common/index.ts b/modules/companies/src/common/index.ts index 23f2b5b..73b6d4b 100644 --- a/modules/companies/src/common/index.ts +++ b/modules/companies/src/common/index.ts @@ -1,3 +1,5 @@ +export * from "./application-configurations"; +export * from "./certificate-configurations"; export * from "./dto/request/access.request.dto"; export * from "./dto/request/company.request.dto"; export * from "./dto/request/people.request.dto"; diff --git a/modules/companies/src/web/applications/application-api.ts b/modules/companies/src/web/applications/application-api.ts new file mode 100644 index 0000000..6d28dce --- /dev/null +++ b/modules/companies/src/web/applications/application-api.ts @@ -0,0 +1,45 @@ +import { z } from "zod/v4"; + +import { + type ApplicationConfigurationInput, + ApplicationConfigurationResponseSchema, + ApplicationConfigurationsResponseSchema, +} from "../../common/application-configurations"; +export const applicationKeys = { company: (id: string) => ["application-configurations", id] }; +export class ApplicationApiError extends Error { + constructor(public readonly code: string) { + super(code); + } +} +async function request( + companyId: string, + values?: ApplicationConfigurationInput, + token?: string, + id?: string +) { + const response = await fetch( + `/api/application-configurations/${companyId}${id ? `/${id}/update` : ""}`, + { + method: values ? "POST" : "GET", + credentials: "same-origin", + headers: values ? { "content-type": "application/json", "x-csrf-token": token ?? "" } : {}, + ...(values ? { body: JSON.stringify({ values }) } : {}), + } + ); + const body: unknown = await response.json(); + if (!response.ok) { + const error = z.object({ code: z.string() }).safeParse(body); + throw new ApplicationApiError(error.success ? error.data.code : "INTERNAL_ERROR"); + } + return body; +} +export const applicationApi = { + async get(companyId: string) { + return ApplicationConfigurationsResponseSchema.parse(await request(companyId)); + }, + async save(companyId: string, values: ApplicationConfigurationInput, token: string, id?: string) { + return ApplicationConfigurationResponseSchema.parse( + await request(companyId, values, token, id) + ); + }, +}; diff --git a/modules/companies/src/web/applications/application-configuration-form.tsx b/modules/companies/src/web/applications/application-configuration-form.tsx new file mode 100644 index 0000000..a6d0409 --- /dev/null +++ b/modules/companies/src/web/applications/application-configuration-form.tsx @@ -0,0 +1,338 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { + ResourceEditor, + catalogApi, + catalogKeys, + inventoryApi, + inventoryKeys, +} from "@metis/resources/client"; +import type { CatalogItemDTO, ResourceDTO } from "@metis/resources/common"; +import { Button } from "@repo/shadcn-ui/components/button"; +import { ContextHelp } from "@repo/shadcn-ui/components/context-help"; +import { FormActions, FormDialog } from "@repo/shadcn-ui/components/form-dialog"; +import { Input } from "@repo/shadcn-ui/components/input"; +import { Label } from "@repo/shadcn-ui/components/label"; +import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action"; +import { Textarea } from "@repo/shadcn-ui/components/textarea"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useId, useRef, useState } from "react"; +import { type UseFormReturn, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; + +import type { CompanyDTO, WorkProfileDTO } from "../../common"; +import { + APPLICATION_STATE_TRANSITIONS, + type ApplicationConfigurationDTO, + type ApplicationConfigurationInput, + ApplicationConfigurationInputSchema, +} from "../../common/application-configurations"; +import { profileApi, profileKeys } from "../profiles/work-profile-api"; +import { WorkProfileForm } from "../profiles/work-profile-form"; + +import { applicationApi, applicationKeys } from "./application-api"; +import { ApplicationFeedback } from "./application-feedback"; +export function ApplicationConfigurationForm({ + company, + csrfToken, + configuration, + initialProfileId = "", + onSaved, +}: { + company: CompanyDTO; + csrfToken: string; + configuration?: ApplicationConfigurationDTO | undefined; + initialProfileId?: string; + onSaved: () => void; +}) { + const { t } = useTranslation("companies"); + const prefix = useId(); + const client = useQueryClient(); + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + const [quick, setQuick] = useState<"profile" | "application">(); + const profiles = useQuery({ + queryKey: profileKeys.company(company.id), + queryFn: () => profileApi.get(company.id), + }); + const resources = useQuery({ + queryKey: inventoryKeys.company(company.id), + queryFn: () => inventoryApi.get(company.id), + }); + const catalog = useQuery({ + queryKey: catalogKeys.scope(company.id), + queryFn: () => catalogApi.get(company.id), + }); + const form = useForm({ + resolver: zodResolver(ApplicationConfigurationInputSchema), + defaultValues: applicationDefaults(configuration, initialProfileId), + }); + const values = form.watch(); + const ready = [profiles.data, resources.data, catalog.data].every(Boolean); + const fixed = Boolean(configuration || initialProfileId); + const states = applicationStates(configuration); + const mutation = useMutation({ + mutationFn: (input: ApplicationConfigurationInput) => + applicationApi.save(company.id, input, csrfToken, configuration?.id), + onSuccess: async () => { + await client.invalidateQueries({ queryKey: applicationKeys.company(company.id) }); + if (mounted.current) { + onSaved(); + } + }, + }); + return ( + <> +
mutation.mutate(input))}> +
+
+
+ + setQuick("profile") } + } + disabled={fixed || !ready} + id={`${prefix}-profile`} + label={t("mail.profile")} + onValueChange={(id) => form.setValue("profileId", id)} + options={applicationProfiles(values.profileId, profiles.data?.profiles)} + placeholder={t("mail.choose")} + required + value={values.profileId} + /> +
+
+ + setQuick("application"), + } + } + disabled={!!configuration || !ready} + id={`${prefix}-resource`} + label={t("applications.resource")} + onValueChange={(id) => form.setValue("resourceId", id)} + options={applicationOptions( + values.resourceId, + resources.data?.resources, + catalog.data?.items + )} + placeholder={t("mail.choose")} + required + value={values.resourceId} + /> +
+
+ + changeDelivery(value, form)} + options={["installed", "web"].map((value) => ({ + value, + label: t(`applications.${value}`), + }))} + placeholder={t("mail.choose")} + required + value={values.delivery} + /> +
+
+ + { + const parsed = ApplicationConfigurationInputSchema.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")} + required + value={values.state} + /> +
+
+ {values.delivery === "installed" && ( +
+ + form.setValue("version", event.target.value || null)} + value={values.version ?? ""} + /> +
+ )} +
+ +