From 77cb8b0b97367bc0f9b60bc05f30b9ff2ccf7a04 Mon Sep 17 00:00:00 2001 From: David Date: Fri, 16 Oct 2026 11:09:56 +0200 Subject: [PATCH] Poder desactivar sedes --- apps/server/package.json | 2 +- apps/server/src/identity.test.ts | 16 +- apps/server/src/inventory.integration.ts | 1 + apps/server/src/sites.integration.test.ts | 121 ++++++++++ apps/server/src/sites.integration.ts | 216 ++++++++++++++++++ apps/web/src/test-fixtures/workspace.ts | 1 + docs/componentes-de-interfaz.md | 9 + docs/sedes.md | 47 ++++ .../src/api/application/asset.repository.ts | 17 +- .../src/api/application/asset.use-cases.ts | 2 +- .../application/workstation-input.mapper.ts | 9 +- modules/assets/src/api/domain/asset.test.ts | 68 ++++++ .../persistence/sequelize-asset.repository.ts | 9 +- .../assets/select/asset-selection.test.tsx | 2 +- .../src/web/assets/shared/inventory.test.tsx | 4 +- .../assets/shared/types/asset-form-props.ts | 1 + .../shared/ui/asset-identity-fields.tsx | 10 +- .../use-workstation-form-controller.ts | 3 +- .../ui/workstation-association-fields.tsx | 4 +- .../api/application/company-input.mapper.ts | 7 +- .../application/company-response.builder.ts | 1 + .../src/api/application/company.repository.ts | 1 + .../src/api/application/company.use-cases.ts | 11 + .../remote-connection-input.mapper.ts | 7 + .../application/remote-connection-policy.ts | 2 +- .../remote-connection.repository.ts | 2 +- .../companies/src/api/domain/company-error.ts | 1 + .../src/api/domain/company.aggregate.test.ts | 61 ++++- .../src/api/domain/company.aggregate.ts | 13 ++ .../src/api/domain/remote-connection.test.ts | 15 +- .../companies/src/api/domain/site.entity.ts | 10 +- .../api/infrastructure/http/company.routes.ts | 19 ++ .../persistence/company-persistence.mapper.ts | 1 + .../persistence/company.model.ts | 2 + .../persistence/migrate-companies.ts | 8 + .../sequelize-company.repository.ts | 33 ++- .../sequelize-remote-connection.repository.ts | 6 +- .../dto/response/company.response.dto.ts | 1 + .../src/common/dto/shared/company.dto.ts | 1 + modules/companies/src/common/locales/en.json | 15 +- modules/companies/src/common/locales/es.json | 15 +- .../companies/shared/companies-page.test.tsx | 61 +++++ .../shared/ui/remote-target-field.tsx | 4 +- .../src/web/shared/client-contracts.test.ts | 15 +- .../web/sites/delete/api/delete-site.api.ts | 5 + .../sites/delete/ui/site-delete-dialog.tsx | 37 +++ .../use-site-section-controller.ts | 37 +++ .../src/web/sites/list/ui/site-section.tsx | 68 +++++- .../controllers/use-site-form-controller.ts | 7 +- .../src/web/sites/shared/ui/site-form.tsx | 59 +++-- 50 files changed, 993 insertions(+), 74 deletions(-) create mode 100644 apps/server/src/sites.integration.test.ts create mode 100644 apps/server/src/sites.integration.ts create mode 100644 docs/sedes.md create mode 100644 modules/companies/src/web/sites/delete/api/delete-site.api.ts create mode 100644 modules/companies/src/web/sites/delete/ui/site-delete-dialog.tsx create mode 100644 modules/companies/src/web/sites/list/controllers/use-site-section-controller.ts diff --git a/apps/server/package.json b/apps/server/package.json index 1eed8ca..2bd59e7 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "node --env-file=../../.env --import tsx --watch src/index.ts", "typecheck": "tsc -p tsconfig.json", - "test": "node --import tsx --test src/app.test.ts src/config/env-schema.test.ts src/infrastructure/http/http-error-diagnostic.test.ts && DB_NAME=metis_desk_test NODE_ENV=test node --env-file=../../.env --import tsx --test src/asset-inventory.integration.test.ts src/identity.test.ts", + "test": "node --import tsx --test src/app.test.ts src/config/env-schema.test.ts src/infrastructure/http/http-error-diagnostic.test.ts && DB_NAME=metis_desk_test NODE_ENV=test node --env-file=../../.env --import tsx --test --test-concurrency=1 src/asset-inventory.integration.test.ts src/sites.integration.test.ts src/identity.test.ts", "build": "tsup --config tsup.config.ts", "start": "node --env-file=../../.env dist/index.js" }, diff --git a/apps/server/src/identity.test.ts b/apps/server/src/identity.test.ts index 3723c94..b6e0a4d 100644 --- a/apps/server/src/identity.test.ts +++ b/apps/server/src/identity.test.ts @@ -49,6 +49,7 @@ import { verifyRelationships } from "./relationships.integration"; import { verifyRemoteConnections } from "./remote.integration"; import { verifyResourceMigration } from "./resource-migration.integration"; import { verifyResources } from "./resources.integration"; +import { verifySites } from "./sites.integration"; import { verifySoftwareInstallations } from "./software-installations.integration"; import { verifySupport } from "./support.integration"; import { verifyTelework } from "./telework.integration"; @@ -524,6 +525,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de ); const noAddressSite = await operator.request(`/companies/${customer.id}/sites`, { name: "Almacén", + active: true, }); assert.equal(noAddressSite.status, 201); @@ -534,7 +536,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de const siteAddress = "Calle Mayor, 10\n28001 Madrid"; const changedSite = await operator.request( `/companies/${customer.id}/sites/${emptySite.id}/update`, - { name: "Sede central", address: siteAddress } + { name: "Sede central", active: true, address: siteAddress } ); assert.equal(changedSite.status, 200); @@ -544,7 +546,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de ); const crossSite = await operator.request( `/companies/${secondCompany.id}/sites/${emptySite.id}/update`, - { name: "Ajena", address: null } + { name: "Ajena", active: true, address: null } ); assert.equal(crossSite.status, 404); @@ -554,6 +556,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de ( await operator.request(`/companies/${customer.id}/sites`, { name: "Oficina", + active: true, address: largeAddress, }) ).status, @@ -563,6 +566,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de ( await operator.request(`/companies/${customer.id}/sites`, { name: "Demasiado", + active: true, address: "a".repeat(10001), }) ).status, @@ -592,7 +596,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de ); const clearedSite = await operator.request( `/companies/${customer.id}/sites/${emptySite.id}/update`, - { name: "Sede central", address: null } + { name: "Sede central", active: true, address: null } ); assert.equal(clearedSite.status, 200); @@ -922,6 +926,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de secondCompany.id, database ); + await verifySites((...args) => operator.request(...args), customer.id, secondCompany.id); await verifyPrinters( (...args) => operator.request(...args), customer.id, @@ -1075,6 +1080,11 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de ); await expectError(await operator.request("/users"), 401, "UNAUTHENTICATED"); await expectError(await operator.request("/companies"), 401, "UNAUTHENTICATED"); + await expectError( + await operator.request(`/companies/${customer.id}/sites/${emptySite.id}/delete`, {}), + 401, + "UNAUTHENTICATED" + ); await assert.rejects( database.query("UPDATE users SET active = 0 WHERE id = :id", { replacements: { id: initial.id }, diff --git a/apps/server/src/inventory.integration.ts b/apps/server/src/inventory.integration.ts index 6a9afc1..7f28de3 100644 --- a/apps/server/src/inventory.integration.ts +++ b/apps/server/src/inventory.integration.ts @@ -57,6 +57,7 @@ export async function verifyInventory( async function site(company: string) { const response = await request(`/companies/${company}/sites`, { name: "Sede inventario", + active: true, address: null, }); diff --git a/apps/server/src/sites.integration.test.ts b/apps/server/src/sites.integration.test.ts new file mode 100644 index 0000000..e4e09f0 --- /dev/null +++ b/apps/server/src/sites.integration.test.ts @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { test } from "node:test"; + +import { DataTypes, Op } from "sequelize"; + +import { CompanyUseCases } from "../../../modules/companies/src/api/application/company.use-cases"; +import { CompanyInputMapper } from "../../../modules/companies/src/api/application/company-input.mapper"; +import { + CompanyModel, + SiteModel, + initializeCompanyModels, +} from "../../../modules/companies/src/api/infrastructure/persistence/company.model"; +import { CompanyPersistenceMapper } from "../../../modules/companies/src/api/infrastructure/persistence/company-persistence.mapper"; +import { migrateCompanies } from "../../../modules/companies/src/api/infrastructure/persistence/migrate-companies"; +import { migratePeople } from "../../../modules/companies/src/api/infrastructure/persistence/migrate-people"; +import { initializePeopleModels } from "../../../modules/companies/src/api/infrastructure/persistence/people.model"; +import { SequelizeCompanyRepository } from "../../../modules/companies/src/api/infrastructure/persistence/sequelize-company.repository"; + +import { createDatabase, readDatabaseEnvironment } from "./config/database"; + +test("MariaDB: archivo reversible y borrado de sedes solo sin referencias", async () => { + const environment = readDatabaseEnvironment(process.env); + + assert.equal(environment.NODE_ENV, "test"); + assert.equal(environment.DB_NAME, "metis_desk_test"); + const database = createDatabase(environment); + const companyIds: string[] = []; + const linksTable = `site_lifecycle_test_${randomUUID().replaceAll("-", "")}`; + let createdLinks = false; + + try { + await migrateCompanies(database); + await migratePeople(database); + initializeCompanyModels(database); + initializePeopleModels(database); + const useCases = new CompanyUseCases( + new SequelizeCompanyRepository(database, new CompanyPersistenceMapper()), + new CompanyInputMapper() + ); + const created = await useCases.create({ name: "Prueba de ciclo de sedes", active: true }); + + assert.ok(created.isSuccess); + const companyId = created.data.id.toPrimitive(); + + companyIds.push(companyId); + const other = await useCases.create({ name: "Otra compañía de prueba de sedes", active: true }); + + assert.ok(other.isSuccess); + companyIds.push(other.data.id.toPrimitive()); + const added = await useCases.addSite(companyId, { name: "Sede", active: true }); + + assert.ok(added.isSuccess); + const site = added.data.sites[0]; + + assert.ok(site); + const siteId = site.id.toPrimitive(); + const foreign = await useCases.removeSite(other.data.id.toPrimitive(), siteId); + + assert.ok(!foreign.isSuccess); + assert.equal(foreign.error.code, "SITE_NOT_FOUND"); + for (const active of [false, false, true]) { + const changed = await useCases.updateSite(companyId, siteId, { name: "Sede", active }); + + assert.ok(changed.isSuccess); + assert.equal(changed.data.sites[0]?.active, active); + const reloaded = await useCases.get(companyId); + + assert.ok(reloaded.isSuccess); + assert.equal(reloaded.data.sites[0]?.active, active); + } + await database.getQueryInterface().createTable(linksTable, { + id: { type: DataTypes.UUID, primaryKey: true }, + siteId: { + type: DataTypes.UUID, + allowNull: false, + references: { model: "company_sites", key: "id" }, + onDelete: "RESTRICT", + }, + }); + createdLinks = true; + await database.getQueryInterface().bulkInsert(linksTable, [{ id: randomUUID(), siteId }]); + const blocked = await useCases.removeSite(companyId, siteId); + + assert.ok(!blocked.isSuccess); + assert.equal(blocked.error.code, "SITE_IN_USE"); + const archived = await useCases.updateSite(companyId, siteId, { name: "Sede", active: false }); + + assert.ok(archived.isSuccess); + await migrateCompanies(database); + const reloaded = await useCases.get(companyId); + + assert.ok(reloaded.isSuccess); + assert.equal(reloaded.data.sites[0]?.active, false); + const stillBlocked = await useCases.removeSite(companyId, siteId); + + assert.ok(!stillBlocked.isSuccess); + assert.equal(stillBlocked.error.code, "SITE_IN_USE"); + await database.getQueryInterface().bulkDelete(linksTable, { siteId }); + const removed = await useCases.removeSite(companyId, siteId); + + assert.ok(removed.isSuccess); + assert.equal(removed.data.sites.length, 0); + const missing = await useCases.removeSite(companyId, siteId); + + assert.ok(!missing.isSuccess); + assert.equal(missing.error.code, "SITE_NOT_FOUND"); + } finally { + try { + if (createdLinks) { + await database.getQueryInterface().dropTable(linksTable); + } + if (companyIds.length) { + await SiteModel.destroy({ where: { companyId: { [Op.in]: companyIds } } }); + await CompanyModel.destroy({ where: { id: { [Op.in]: companyIds } } }); + } + } finally { + await database.close(); + } + } +}); diff --git a/apps/server/src/sites.integration.ts b/apps/server/src/sites.integration.ts new file mode 100644 index 0000000..e7cd4f5 --- /dev/null +++ b/apps/server/src/sites.integration.ts @@ -0,0 +1,216 @@ +import assert from "node:assert/strict"; + +import { + AssetResponseSchema, + CatalogResponseSchema, + WorkstationResponseSchema, +} from "@metis/assets/common"; +import { CompanyErrorResponseSchema, CompanyResponseSchema } from "@metis/companies/common"; +import { z } from "zod/v4"; + +type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise; + +export async function verifySites(request: Request, companyId: string, otherId: string) { + const path = `/companies/${companyId}/sites`; + + const createSite = async (name: string) => { + const response = await request(path, { name, active: true }); + + assert.equal(response.status, 201); + const site = CompanyResponseSchema.parse(await response.json()).company.sites.find( + (item) => item.name === name + ); + + assert.ok(site); + + return site; + }; + + const expectError = async (response: Response, status: number, code: string) => { + assert.equal(response.status, status); + assert.equal(CompanyErrorResponseSchema.parse(await response.json()).code, code); + }; + + const empty = await createSite("Sede vacía para borrar"); + + await expectError( + await request(`${path}/${empty.id}/update`, { name: empty.name, active: "false" }), + 400, + "INVALID_REQUEST" + ); + await expectError(await request(`${path}/invalid/delete`, {}), 400, "INVALID_REQUEST"); + await expectError( + await request(`${path}/${empty.id}/delete`, { cascade: true }), + 400, + "INVALID_REQUEST" + ); + await expectError( + await request(`/companies/${otherId}/sites/${empty.id}/delete`, {}), + 404, + "SITE_NOT_FOUND" + ); + for (const active of [false, false, true, false]) { + const response = await request(`${path}/${empty.id}/update`, { name: empty.name, active }); + + assert.equal(response.status, 200); + assert.equal( + CompanyResponseSchema.parse(await response.json()).company.sites.find( + (site) => site.id === empty.id + )?.active, + active + ); + } + const reloaded = await request(`/companies/${companyId}`); + + assert.equal( + CompanyResponseSchema.parse(await reloaded.json()).company.sites.find( + (site) => site.id === empty.id + )?.active, + false + ); + const removed = await request(`${path}/${empty.id}/delete`, {}); + + assert.equal(removed.status, 200); + assert.equal( + CompanyResponseSchema.parse(await removed.json()).company.sites.some( + (site) => site.id === empty.id + ), + false + ); + await expectError(await request(`${path}/${empty.id}/delete`, {}), 404, "SITE_NOT_FOUND"); + + const used = await createSite("Sede con historial de activo"); + const catalog = CatalogResponseSchema.parse( + await (await request(`/asset-catalog/${companyId}`)).json() + ); + const template = catalog.items.find( + (item) => + item.values.kind === "template" && item.values.name === "Monitor" && item.effectiveActive + ); + + assert.ok(template); + const assetInput = { + name: "Activo para sede", + templateId: template.id, + siteId: used.id, + status: "operational", + notes: "", + characteristics: {}, + }; + const created = await request(`/assets/${companyId}`, { values: assetInput }); + + assert.equal(created.status, 201); + const asset = AssetResponseSchema.parse(await created.json()).asset; + + await expectError(await request(`${path}/${used.id}/delete`, {}), 409, "SITE_IN_USE"); + assert.equal( + (await request(`${path}/${used.id}/update`, { name: used.name, active: false })).status, + 200 + ); + const rejected = await request(`/assets/${companyId}`, { values: assetInput }); + + assert.equal(rejected.status, 404); + assert.equal(z.object({ code: z.string() }).parse(await rejected.json()).code, "SITE_NOT_FOUND"); + assert.equal( + ( + await request(`/assets/${companyId}/${asset.id}/update`, { + values: { ...assetInput, notes: "Actualización en sede archivada" }, + }) + ).status, + 200 + ); + assert.equal( + ( + await request(`/assets/${companyId}/${asset.id}/update`, { + values: { ...assetInput, siteId: null }, + }) + ).status, + 200 + ); + await expectError(await request(`${path}/${used.id}/delete`, {}), 409, "SITE_IN_USE"); + + const workstationSite = await createSite("Sede con historial de puesto"); + const stationInput = { + name: "Puesto de sede", + siteId: workstationSite.id, + personId: null, + active: true, + assetIds: [], + computerAssetId: null, + }; + const stationResponse = await request(`/assets/${companyId}/workstations`, { + values: stationInput, + }); + + assert.equal(stationResponse.status, 201); + const station = WorkstationResponseSchema.parse(await stationResponse.json()).workstation; + + assert.equal( + ( + await request(`${path}/${workstationSite.id}/update`, { + name: workstationSite.name, + active: false, + }) + ).status, + 200 + ); + assert.equal( + (await request(`/assets/${companyId}/workstations`, { values: stationInput })).status, + 404 + ); + assert.equal( + ( + await request(`/assets/${companyId}/workstations/${station.id}/update`, { + values: stationInput, + }) + ).status, + 200 + ); + assert.equal( + ( + await request(`/assets/${companyId}/workstations/${station.id}/update`, { + values: { ...stationInput, siteId: null }, + }) + ).status, + 200 + ); + await expectError(await request(`${path}/${workstationSite.id}/delete`, {}), 409, "SITE_IN_USE"); + + const remoteSite = await createSite("Sede de conexión remota"); + const company = CompanyResponseSchema.parse( + await (await request(`/companies/${companyId}`)).json() + ).company; + const person = company.people[0]; + + assert.ok(person); + const remoteInput = { + personId: person.id, + sourceKind: "personal_computer", + sourceAssetId: null, + targetKind: "network", + targetId: null, + siteId: remoteSite.id, + method: "VPN", + requiresVpn: false, + state: "pending", + notes: null, + }; + const remote = await request(`/remote-connections/${companyId}`, { values: remoteInput }); + + assert.equal(remote.status, 201); + await expectError(await request(`${path}/${remoteSite.id}/delete`, {}), 409, "SITE_IN_USE"); + assert.equal( + (await request(`${path}/${remoteSite.id}/update`, { name: remoteSite.name, active: false })) + .status, + 200 + ); + const archivedRemote = await request(`/remote-connections/${companyId}`, { + values: { ...remoteInput, method: "Otra VPN" }, + }); + + assert.equal(archivedRemote.status, 404); + assert.equal( + z.object({ code: z.string() }).parse(await archivedRemote.json()).code, + "REMOTE_REFERENCE_NOT_FOUND" + ); +} diff --git a/apps/web/src/test-fixtures/workspace.ts b/apps/web/src/test-fixtures/workspace.ts index b9b8ee7..67b6cd0 100644 --- a/apps/web/src/test-fixtures/workspace.ts +++ b/apps/web/src/test-fixtures/workspace.ts @@ -23,6 +23,7 @@ export const workspaceCompany: CompanyDTO = { id: "0199b200-0000-7000-8000-000000000003", companyId: "0199b200-0000-7000-8000-000000000001", name: "Sede central", + active: true, address: null, phone: null, }, diff --git a/docs/componentes-de-interfaz.md b/docs/componentes-de-interfaz.md index 5e987df..630aa7a 100644 --- a/docs/componentes-de-interfaz.md +++ b/docs/componentes-de-interfaz.md @@ -637,3 +637,12 @@ mantienen como metadatos de consulta. Los listados de activos y recursos lo utilizan para sus familias; el picker de activos lo hereda mediante `AssetList`. La barra ocupa únicamente el ancho disponible, mantiene una sola fila y no comprime los nombres. Permite desplazamiento horizontal nativo con tacto o trackpad y muestra botones laterales solo cuando hay desbordamiento. Los botones se deshabilitan al llegar a cada extremo. La barra de desplazamiento nativa permanece oculta, evitando controles verticales accidentales; los botones conservan nombre accesible y tooltip. Conserva la semántica y el teclado de Base UI. Al cambiar de familia, enfocar una pestaña o redimensionar el contenedor, revela la pestaña correspondiente moviendo solo el eje horizontal. Un `ResizeObserver` actualiza los extremos al abrir o cerrar paneles, redimensionar la ventana o cambiar las etiquetas; se retiran el observer y los listeners al desmontar. La raíz `Tabs` y los paneles consumidores mantienen `min-w-0` para permitir que la barra se reduzca dentro de flex y grid. + +## Archivo y eliminación de sedes + +`SiteSection` conserva el listado común, añade el estado y un filtro para consultar las +archivadas. Su controller coordina la selección y el cambio de estado; `SiteForm` +reutiliza `SwitchField`. `SiteDeleteDialog` compone `ConfirmDeleteDialog`, presenta el +nombre de la sede y conserva el error de negocio dentro del modal. La caché de detalle +de compañía se actualiza tras completar cada operación. Los selectores conservan la +sede anterior archivada y excluyen las demás archivadas. Véase [Sedes](sedes.md). diff --git a/docs/sedes.md b/docs/sedes.md new file mode 100644 index 0000000..d4762ca --- /dev/null +++ b/docs/sedes.md @@ -0,0 +1,47 @@ +# Sedes de compañía + +Cada sede pertenece a una compañía y conserva su UUID, nombre, dirección y teléfono. +El estado `active` distingue sedes disponibles y archivadas. La migración explícita +`migrateCompanies` añade la columna relacional no nullable y mantiene activas las sedes +existentes; puede volver a ejecutarse sin reinicializar los estados. + +## Archivo y reactivación + +El listado muestra las sedes activas por defecto. «Mostrar también las sedes archivadas» +permite consultar y reactivar las anteriores. El menú de fila ofrece Archivar/Reactivar, +Editar y Eliminar sede; el formulario de edición también permite cambiar el estado. + +Archivar no borra ni desvincula datos. La sede sigue resolviéndose en fichas e historiales. +Los selectores de activos, puestos y destinos de red ofrecen solo sedes activas y, al +editar, la sede anterior aunque esté archivada. El servidor aplica el mismo contrato: +permite conservar la referencia anterior y rechaza nuevas asociaciones a sedes archivadas. +En accesos remotos el destino sigue siendo inmutable. + +## Borrado definitivo + +`POST /api/companies/:companyId/sites/:id/delete` recibe un objeto vacío y devuelve la +compañía actualizada. Requiere la misma sesión y protección CSRF que las demás escrituras +sobre compañías; valida UUID, pertenencia, cuerpo y ausencia de query adicional. + +La operación bloquea la compañía dentro de una transacción y elimina exclusivamente +la sede solicitada. Las claves foráneas `RESTRICT` impiden borrar sedes referenciadas, +incluidas revisiones históricas de activos, puestos y accesos remotos. No se propagan +borrados ni se limpian referencias para permitir la eliminación. Una referencia produce +`409 SITE_IN_USE` y el diálogo propone archivar; una sede ausente o de otra compañía +produce `404 SITE_NOT_FOUND`. Repetir un borrado no genera otros efectos. + +Los fallos de infraestructura distintos de una restricción referencial se propagan al +manejador HTTP común. La interfaz mantiene el diálogo abierto si el borrado falla y +actualiza la caché de la compañía únicamente tras confirmar la operación. + +## Validación + +La prueba aislada `apps/server/src/sites.integration.test.ts` usa MariaDB y comprueba +persistencia, repetición de migración, referencias con FK y borrado tras liberar las +asociaciones. Solo crea y limpia sus propios registros en la base de pruebas. + +Las pruebas de dominio cubren archivo, reactivación, identidad y repetición del estado. +Las pruebas web comprueban textos es/en, el filtro inicial y el control de estado. +`apps/server/src/sites.integration.ts`, invocado por la suite HTTP/MariaDB, comprueba +validación, pertenencia, borrado de una sede vacía, conservación de referencias, +restricciones históricas y rechazo de nuevas asociaciones a sedes archivadas. diff --git a/modules/assets/src/api/application/asset.repository.ts b/modules/assets/src/api/application/asset.repository.ts index e186648..6148bcc 100644 --- a/modules/assets/src/api/application/asset.repository.ts +++ b/modules/assets/src/api/application/asset.repository.ts @@ -15,7 +15,7 @@ export type InventoryContext = Readonly<{ brands: readonly Brand[]; assets: readonly Asset[]; relationships: readonly AssetRelationship[]; - sites: readonly UniqueID[]; + sites: readonly { id: UniqueID; active: boolean }[]; people: readonly { id: UniqueID; active: boolean }[]; actorNames: ReadonlyMap; }>; @@ -67,10 +67,21 @@ export interface IAssetRepository { >; } -export function validateAssetReferences(context: InventoryContext, values: AssetValues) { +export function validateAssetReferences( + context: InventoryContext, + values: AssetValues, + previous?: Asset +) { const next = values.toPrimitive(); - if (next.siteId && !context.sites.some((site) => site.toPrimitive() === next.siteId)) { + if ( + next.siteId && + !context.sites.some( + (site) => + site.id.toPrimitive() === next.siteId && + (site.active || previous?.values.toPrimitive().siteId === next.siteId) + ) + ) { return Result.fail(new AssetCatalogError("SITE_NOT_FOUND")); } diff --git a/modules/assets/src/api/application/asset.use-cases.ts b/modules/assets/src/api/application/asset.use-cases.ts index 66ef6b3..a112b68 100644 --- a/modules/assets/src/api/application/asset.use-cases.ts +++ b/modules/assets/src/api/application/asset.use-cases.ts @@ -55,7 +55,7 @@ export class AssetUseCases { if (!mapped.isSuccess) { return Result.fail(mapped.error); } - const references = validateAssetReferences(context, mapped.data.values); + const references = validateAssetReferences(context, mapped.data.values, previous); if (!references.isSuccess) { return Result.fail(references.error); diff --git a/modules/assets/src/api/application/workstation-input.mapper.ts b/modules/assets/src/api/application/workstation-input.mapper.ts index f5be081..0b3ed26 100644 --- a/modules/assets/src/api/application/workstation-input.mapper.ts +++ b/modules/assets/src/api/application/workstation-input.mapper.ts @@ -20,7 +20,14 @@ export function mapWorkstationInput( } const values = mapped.data.toPrimitive(); - if (values.siteId && !context.sites.some((id) => id.toPrimitive() === values.siteId)) { + if ( + values.siteId && + !context.sites.some( + (site) => + site.id.toPrimitive() === values.siteId && + (site.active || previous?.values.toPrimitive().siteId === values.siteId) + ) + ) { return Result.fail(new AssetCatalogError("SITE_NOT_FOUND")); } const person = context.people.find((item) => item.id.toPrimitive() === values.personId); diff --git a/modules/assets/src/api/domain/asset.test.ts b/modules/assets/src/api/domain/asset.test.ts index d9298b2..8d150f8 100644 --- a/modules/assets/src/api/domain/asset.test.ts +++ b/modules/assets/src/api/domain/asset.test.ts @@ -3,10 +3,17 @@ import { test } from "node:test"; import { UniqueID } from "@repo/rdx-ddd"; +import type { InventoryContext } from "../application/asset.repository"; +import { validateAssetReferences } from "../application/asset.repository"; +import { mapWorkstationInput } from "../application/workstation-input.mapper"; + import { Asset } from "./asset.aggregate"; +import { AssetCatalog } from "./asset-catalog.aggregate"; import { assetDisplayStatus, assetTechnicalStatus, canAssignAsset } from "./asset-state"; import { type AssetInput, AssetInputSchema } from "./asset-values"; import { AssetSpecification, AssetValues } from "./value-objects/asset-values.vo"; +import { WorkstationValues } from "./value-objects/workstation-values.vo"; +import { Workstation } from "./workstation.aggregate"; const company = UniqueID.generateNewID(); const actor = UniqueID.generateNewID(); @@ -217,3 +224,64 @@ test("Fecha de alta opcional: calendario real, independencia de compra, revision assert.equal(cleared.data.history.length, 3); assert.equal(cleared.data.history[1]?.values.toPrimitive().registeredOn, "2024-02-29"); }); + +test("sedes archivadas: nuevas referencias rechazadas y referencias anteriores conservadas", () => { + const siteId = UniqueID.generateNewID(); + const context: InventoryContext = { + catalog: AssetCatalog.create(), + brands: [], + workstations: [], + assets: [], + relationships: [], + sites: [{ id: siteId, active: false }], + people: [], + actorNames: new Map(), + }; + const spec = specification(); + const atSite = AssetValues.create({ ...input(), siteId: siteId.toPrimitive() }, spec); + + assert.ok(atSite.isSuccess); + const created = Asset.create(company, spec, atSite.data, actor, "2026-10-16T10:00:00.000Z"); + + assert.ok(created.isSuccess); + const rejected = validateAssetReferences(context, atSite.data); + + assert.ok(!rejected.isSuccess); + assert.equal(rejected.error.code, "SITE_NOT_FOUND"); + assert.ok(validateAssetReferences(context, atSite.data, created.data).isSuccess); + assert.ok( + validateAssetReferences({ ...context, sites: [{ id: siteId, active: true }] }, atSite.data) + .isSuccess + ); + assert.ok( + !validateAssetReferences({ ...context, sites: [] }, atSite.data, created.data).isSuccess + ); + const stationInput = { + name: "Puesto", + active: true, + siteId: siteId.toPrimitive(), + personId: null, + assetIds: [], + }; + const stationValues = WorkstationValues.create(stationInput); + + assert.ok(stationValues.isSuccess); + const station = Workstation.create( + company, + stationValues.data, + actor, + "2026-10-16T10:00:00.000Z" + ); + + assert.ok(station.isSuccess); + const stationRejected = mapWorkstationInput(stationInput, context); + + assert.ok(!stationRejected.isSuccess); + assert.equal(stationRejected.error.code, "SITE_NOT_FOUND"); + assert.ok(mapWorkstationInput(stationInput, context, station.data).isSuccess); + assert.ok(mapWorkstationInput({ ...stationInput, siteId: null }, context).isSuccess); + assert.ok( + mapWorkstationInput(stationInput, { ...context, sites: [{ id: siteId, active: true }] }) + .isSuccess + ); +}); diff --git a/modules/assets/src/api/infrastructure/persistence/sequelize-asset.repository.ts b/modules/assets/src/api/infrastructure/persistence/sequelize-asset.repository.ts index 181ec37..7b49d0a 100644 --- a/modules/assets/src/api/infrastructure/persistence/sequelize-asset.repository.ts +++ b/modules/assets/src/api/infrastructure/persistence/sequelize-asset.repository.ts @@ -71,8 +71,8 @@ export class SequelizeAssetRepository implements IAssetRepository { companyId.toPrimitive(), transaction ); - const sites = await this.database.query<{ id: string }>( - "SELECT id FROM company_sites WHERE companyId=:id", + const sites = await this.database.query<{ id: string; active: boolean }>( + "SELECT id,active FROM company_sites WHERE companyId=:id", { replacements, type: QueryTypes.SELECT, transaction } ); const people = await this.database.query<{ id: string; active: boolean }>( @@ -141,7 +141,10 @@ export class SequelizeAssetRepository implements IAssetRepository { relationships, catalog, assets, - sites: sites.map((site) => inventoryIdentity(site.id)), + sites: sites.map((site) => ({ + id: inventoryIdentity(site.id), + active: Boolean(site.active), + })), people: people.map((person) => ({ id: inventoryIdentity(person.id), active: Boolean(person.active), diff --git a/modules/assets/src/web/assets/select/asset-selection.test.tsx b/modules/assets/src/web/assets/select/asset-selection.test.tsx index 4a22be0..e7d8233 100644 --- a/modules/assets/src/web/assets/select/asset-selection.test.tsx +++ b/modules/assets/src/web/assets/select/asset-selection.test.tsx @@ -65,7 +65,7 @@ async function render(language: string, node: ReactNode) { const listProps = { assets, catalog: [], - sites: [{ id: id(4), name: "Sede Central" }], + sites: [{ id: id(4), name: "Sede Central", active: true }], templateId: null, onTemplateChange: () => undefined, search: "", diff --git a/modules/assets/src/web/assets/shared/inventory.test.tsx b/modules/assets/src/web/assets/shared/inventory.test.tsx index 79cb9fc..416e50c 100644 --- a/modules/assets/src/web/assets/shared/inventory.test.tsx +++ b/modules/assets/src/web/assets/shared/inventory.test.tsx @@ -364,7 +364,7 @@ test("alta de puesto: sede única automática, responsable opcional y cuatro pas language, undefined} /> ); @@ -451,7 +451,7 @@ test("la ficha lateral agrupa información y ofrece acciones superiores y enlace companyId={companyId} lookups={{ ...lookups, - sites: [{ id: siteId, name: "Fábrica" }], + sites: [{ id: siteId, name: "Fábrica", active: true }], }} onClose={() => undefined} onDelete={() => undefined} diff --git a/modules/assets/src/web/assets/shared/types/asset-form-props.ts b/modules/assets/src/web/assets/shared/types/asset-form-props.ts index d09b1c9..4bbc649 100644 --- a/modules/assets/src/web/assets/shared/types/asset-form-props.ts +++ b/modules/assets/src/web/assets/shared/types/asset-form-props.ts @@ -5,6 +5,7 @@ export type InventoryLookups = { sites: readonly { id: string; name: string; + active: boolean; }[]; people: readonly { id: string; diff --git a/modules/assets/src/web/assets/shared/ui/asset-identity-fields.tsx b/modules/assets/src/web/assets/shared/ui/asset-identity-fields.tsx index f94f4b2..f2df121 100644 --- a/modules/assets/src/web/assets/shared/ui/asset-identity-fields.tsx +++ b/modules/assets/src/web/assets/shared/ui/asset-identity-fields.tsx @@ -139,10 +139,12 @@ export function AssetIdentityFields({ id={`${prefix}-site`} label={t("site")} onValueChange={(id) => form.setValue("siteId", id || null)} - options={lookups.sites.map((site) => ({ - value: site.id, - label: site.name, - }))} + options={lookups.sites + .filter((site) => site.active || site.id === asset?.values.siteId) + .map((site) => ({ + value: site.id, + label: site.name, + }))} value={values.siteId ?? ""} /> diff --git a/modules/assets/src/web/workstations/shared/controllers/use-workstation-form-controller.ts b/modules/assets/src/web/workstations/shared/controllers/use-workstation-form-controller.ts index e480801..5b39419 100644 --- a/modules/assets/src/web/workstations/shared/controllers/use-workstation-form-controller.ts +++ b/modules/assets/src/web/workstations/shared/controllers/use-workstation-form-controller.ts @@ -27,7 +27,8 @@ export function useWorkstationFormController({ const mounted = useRef(true); const isWizard = !workstation; const [step, setStep] = useState<1 | 2 | 3 | 4>(1); - const initialSite = lookups.sites.length === 1 ? lookups.sites[0] : undefined; + const activeSites = lookups.sites.filter((site) => site.active); + const initialSite = activeSites.length === 1 ? activeSites[0] : undefined; const form = useForm({ resolver: zodResolver(WorkstationInputSchema), defaultValues: workstation?.values ?? { diff --git a/modules/assets/src/web/workstations/shared/ui/workstation-association-fields.tsx b/modules/assets/src/web/workstations/shared/ui/workstation-association-fields.tsx index ef44891..25cbbd5 100644 --- a/modules/assets/src/web/workstations/shared/ui/workstation-association-fields.tsx +++ b/modules/assets/src/web/workstations/shared/ui/workstation-association-fields.tsx @@ -40,7 +40,9 @@ export function WorkstationAssociationFields({ id={`${prefix}-site`} label={t("site")} onValueChange={(id) => onSelect("siteId", id)} - options={lookups.sites.map((item) => ({ value: item.id, label: item.name }))} + options={lookups.sites + .filter((item) => item.active || item.id === workstation?.values.siteId) + .map((item) => ({ value: item.id, label: item.name }))} placeholder={t("noAssignment")} required={required} value={values.siteId ?? ""} diff --git a/modules/companies/src/api/application/company-input.mapper.ts b/modules/companies/src/api/application/company-input.mapper.ts index e3e3334..9c14dd7 100644 --- a/modules/companies/src/api/application/company-input.mapper.ts +++ b/modules/companies/src/api/application/company-input.mapper.ts @@ -99,6 +99,11 @@ export class CompanyInputMapper { return Result.fail(new CompanyError("INVALID_REQUEST")); } - return Result.ok({ name: name.data, address: address.data, phone: phone.data }); + return Result.ok({ + name: name.data, + active: dto.active, + address: address.data, + phone: phone.data, + }); } } diff --git a/modules/companies/src/api/application/company-response.builder.ts b/modules/companies/src/api/application/company-response.builder.ts index 04375c2..c6ff47c 100644 --- a/modules/companies/src/api/application/company-response.builder.ts +++ b/modules/companies/src/api/application/company-response.builder.ts @@ -59,6 +59,7 @@ export function buildCompanyResponse(company: Company): CompanyDTO { id: site.id.toPrimitive(), companyId: site.companyId.toPrimitive(), name: site.name.toPrimitive(), + active: site.active, address: site.address.getOrUndefined()?.toPrimitive() ?? null, phone: site.phone.getOrUndefined()?.toPrimitive() ?? null, })), diff --git a/modules/companies/src/api/application/company.repository.ts b/modules/companies/src/api/application/company.repository.ts index bcfb711..739dc76 100644 --- a/modules/companies/src/api/application/company.repository.ts +++ b/modules/companies/src/api/application/company.repository.ts @@ -12,6 +12,7 @@ export interface ICompanyRepository { id: UniqueID, change: (company: Company) => Result ): Promise>; + removeSite(id: UniqueID, siteId: UniqueID): Promise>; removePerson(id: UniqueID, personId: UniqueID): Promise>; removeAssignment( id: UniqueID, diff --git a/modules/companies/src/api/application/company.use-cases.ts b/modules/companies/src/api/application/company.use-cases.ts index c4d277b..18da72a 100644 --- a/modules/companies/src/api/application/company.use-cases.ts +++ b/modules/companies/src/api/application/company.use-cases.ts @@ -209,6 +209,17 @@ export class CompanyUseCases { return this.companies.removeAssignment(parent.data, person.data, assignment.data); } + async removeSite(companyId: string, id: string) { + const parent = this.input.id(companyId); + const child = this.input.id(id); + + if (!(parent.isSuccess && child.isSuccess)) { + return Result.fail(new CompanyError("INVALID_REQUEST")); + } + + return this.companies.removeSite(parent.data, child.data); + } + async addSite(id: string, dto: SiteRequestDTO) { const parsed = this.input.id(id); const props = this.input.site(dto); diff --git a/modules/companies/src/api/application/remote-connection-input.mapper.ts b/modules/companies/src/api/application/remote-connection-input.mapper.ts index 667db00..a401db8 100644 --- a/modules/companies/src/api/application/remote-connection-input.mapper.ts +++ b/modules/companies/src/api/application/remote-connection-input.mapper.ts @@ -26,6 +26,13 @@ export function mapRemoteInput( if (!remoteReferences(values, context)) { return Result.fail(new RemoteConnectionError("REMOTE_REFERENCE_NOT_FOUND")); } + if ( + !previous && + input.siteId && + !context.sites.some((site) => site.id.toPrimitive() === input.siteId && site.active) + ) { + return Result.fail(new RemoteConnectionError("REMOTE_REFERENCE_NOT_FOUND")); + } // Se documentan conexiones existentes aunque su autorización ya no sea vigente; se señalan para revisión. if ( !values.closed && diff --git a/modules/companies/src/api/application/remote-connection-policy.ts b/modules/companies/src/api/application/remote-connection-policy.ts index 9321713..8cc0417 100644 --- a/modules/companies/src/api/application/remote-connection-policy.ts +++ b/modules/companies/src/api/application/remote-connection-policy.ts @@ -9,7 +9,7 @@ export function remoteReferences(values: RemoteConnectionValues, context: Remote return ( context.people.some((p) => p.id.equals(values.personId)) && (!v.sourceAssetId || context.assets.some((a) => a.id.toPrimitive() === v.sourceAssetId)) && - (!v.siteId || context.sites.some((s) => s.toPrimitive() === v.siteId)) && + (!v.siteId || context.sites.some((s) => s.id.toPrimitive() === v.siteId)) && (v.targetKind === "network" || (v.targetKind === "asset" ? context.assets.some((a) => a.id.toPrimitive() === v.targetId) diff --git a/modules/companies/src/api/application/remote-connection.repository.ts b/modules/companies/src/api/application/remote-connection.repository.ts index 19fccab..65efe2e 100644 --- a/modules/companies/src/api/application/remote-connection.repository.ts +++ b/modules/companies/src/api/application/remote-connection.repository.ts @@ -9,7 +9,7 @@ export type RemoteContext = Readonly<{ connections: readonly RemoteConnection[]; authorizations: readonly TeleworkAuthorization[]; people: readonly { id: UniqueID; active: boolean }[]; - sites: readonly UniqueID[]; + sites: readonly { id: UniqueID; active: boolean }[]; assets: readonly { id: UniqueID; notRetired: boolean }[]; resources: readonly { id: UniqueID; active: boolean }[]; actors: ReadonlyMap; diff --git a/modules/companies/src/api/domain/company-error.ts b/modules/companies/src/api/domain/company-error.ts index cd071f3..db4cb2b 100644 --- a/modules/companies/src/api/domain/company-error.ts +++ b/modules/companies/src/api/domain/company-error.ts @@ -3,6 +3,7 @@ export type CompanyErrorCode = | "COMPANY_NOT_FOUND" | "CONTACT_NOT_FOUND" | "SITE_NOT_FOUND" + | "SITE_IN_USE" | "PERSON_NOT_FOUND" | "PERSON_IN_USE" | "DEPARTMENT_NOT_FOUND" diff --git a/modules/companies/src/api/domain/company.aggregate.test.ts b/modules/companies/src/api/domain/company.aggregate.test.ts index 6093f04..821112e 100644 --- a/modules/companies/src/api/domain/company.aggregate.test.ts +++ b/modules/companies/src/api/domain/company.aggregate.test.ts @@ -56,7 +56,7 @@ test("compañía, contacto y sede tienen UUID v7 propios y se conserva la identi test("la pertenencia impide asociar o editar entidades de otra compañía", () => { const company = companyFixture(); const other = companyFixture("Otra"); - const props = input.site({ name: "Central", address: null }); + const props = input.site({ active: true, name: "Central", address: null }); assert.ok(props.isSuccess); const site = Site.create({ ...props.data, companyId: other.id }); @@ -90,8 +90,9 @@ function inputContact() { test("la dirección y el teléfono son optativos y se pueden borrar", () => { const company = companyFixture(); - const empty = input.site({ name: "Central", address: null, phone: null }); + const empty = input.site({ active: true, name: "Central", address: null, phone: null }); const full = input.site({ + active: true, name: "Central", address: "Calle Mayor, 10\n28001 Madrid", phone: "+34 900 123 456", @@ -130,7 +131,57 @@ test("nombres, email, teléfono y longitud de dirección se validan en los Value }).isSuccess, false ); - assert.equal(input.site({ name: "Central", address: "x".repeat(10001) }).isSuccess, false); - assert.equal(input.site({ name: "Central", address: "x".repeat(10000) }).isSuccess, true); - assert.equal(input.site({ name: "Central", address: null, phone: "abc" }).isSuccess, false); + assert.equal( + input.site({ active: true, name: "Central", address: "x".repeat(10001) }).isSuccess, + false + ); + assert.equal( + input.site({ active: true, name: "Central", address: "x".repeat(10000) }).isSuccess, + true + ); + assert.equal( + input.site({ active: true, name: "Central", address: null, phone: "abc" }).isSuccess, + false + ); +}); + +test("sedes: archivar y reactivar conserva identidad y datos; repetir el estado es idempotente", () => { + const company = companyFixture(); + const props = input.site({ + name: "Central", + active: true, + address: "Calle Mayor", + phone: "900123456", + }); + + assert.ok(props.isSuccess); + const site = Site.create({ ...props.data, companyId: company.id }); + + assert.ok(site.isSuccess); + const added = company.addSite(site.data); + + assert.ok(added.isSuccess); + const archived = added.data.updateSite(site.data.id, { ...props.data, active: false }); + + assert.ok(archived.isSuccess); + assert.equal(archived.data.sites[0]?.active, false); + assert.ok(archived.data.sites[0]?.id.equals(site.data.id)); + assert.equal(archived.data.sites[0]?.address.getOrUndefined()?.toPrimitive(), "Calle Mayor"); + assert.equal(added.data.sites[0]?.active, true); + const repeated = archived.data.updateSite(site.data.id, { ...props.data, active: false }); + + assert.ok(repeated.isSuccess); + assert.equal(repeated.data, archived.data); + const restored = archived.data.updateSite(site.data.id, props.data); + + assert.ok(restored.isSuccess); + assert.equal(restored.data.sites[0]?.active, true); + const removed = restored.data.removeSite(site.data.id); + + assert.ok(removed.isSuccess); + assert.equal(removed.data.sites.length, 0); + const missing = removed.data.removeSite(site.data.id); + + assert.ok(!missing.isSuccess); + assert.equal(missing.error.code, "SITE_NOT_FOUND"); }); diff --git a/modules/companies/src/api/domain/company.aggregate.ts b/modules/companies/src/api/domain/company.aggregate.ts index d1ecef1..f9162e0 100644 --- a/modules/companies/src/api/domain/company.aggregate.ts +++ b/modules/companies/src/api/domain/company.aggregate.ts @@ -333,6 +333,19 @@ export class Company extends AggregateRoot { ); } + removeSite(id: UniqueID): Result { + if (!this.sites.some((site) => site.id.equals(id))) { + return Result.fail(new CompanyError("SITE_NOT_FOUND")); + } + + return Result.ok( + new Company( + { ...this.props, sites: this.sites.filter((site) => !site.id.equals(id)) }, + this.id + ) + ); + } + addSite(site: Site): Result { if (!site.companyId.equals(this.id)) { return Result.fail(new CompanyError("INVALID_REQUEST")); diff --git a/modules/companies/src/api/domain/remote-connection.test.ts b/modules/companies/src/api/domain/remote-connection.test.ts index 0339391..9b0b5d9 100644 --- a/modules/companies/src/api/domain/remote-connection.test.ts +++ b/modules/companies/src/api/domain/remote-connection.test.ts @@ -39,7 +39,7 @@ const context: RemoteContext = { connections: [], authorizations: [], people: [{ id: person, active: true }], - sites: [site], + sites: [{ id: site, active: true }], assets: [ { id: source, notRetired: true }, { id: target, notRetired: true }, @@ -289,3 +289,16 @@ test("remotos: persistencia conserva auditoría y detecta referencias, claves e ) ); }); + +test("remotos: una sede archivada conserva conexiones existentes pero rechaza nuevas", () => { + const inactive: RemoteContext = { ...context, sites: [{ id: site, active: false }] }; + const atSite = { ...input, siteId: site.toPrimitive() }; + const rejected = mapRemoteInput(atSite, inactive); + + assert.ok(!rejected.isSuccess); + assert.equal(rejected.error.code, "REMOTE_REFERENCE_NOT_FOUND"); + const previous = create(atSite); + + assert.ok(mapRemoteInput({ ...atSite, notes: "Conservada" }, inactive, previous).isSuccess); + assert.ok(mapRemoteInput(atSite, context).isSuccess); +}); diff --git a/modules/companies/src/api/domain/site.entity.ts b/modules/companies/src/api/domain/site.entity.ts index 481dcc1..e833791 100644 --- a/modules/companies/src/api/domain/site.entity.ts +++ b/modules/companies/src/api/domain/site.entity.ts @@ -10,6 +10,7 @@ import type { SiteAddress } from "./value-objects/site-address.vo"; export type SiteProps = Readonly<{ companyId: UniqueID; name: Name; + active: boolean; address: Maybe; phone: Maybe; }>; @@ -35,6 +36,10 @@ export class Site extends DomainEntity { return this.props.name; } + get active() { + return this.props.active; + } + get address() { return this.props.address; } @@ -54,7 +59,10 @@ export class Site extends DomainEntity { const nextPhone = values.phone.getOrUndefined(); const samePhone = currentPhone ? currentPhone.equals(nextPhone) : nextPhone === undefined; - return this.name.equals(values.name) && sameAddress && samePhone + return this.name.equals(values.name) && + this.active === values.active && + sameAddress && + samePhone ? Result.ok(this) : Result.ok(new Site({ ...values, companyId: this.companyId }, this.id)); } diff --git a/modules/companies/src/api/infrastructure/http/company.routes.ts b/modules/companies/src/api/infrastructure/http/company.routes.ts index afb49ca..4154555 100644 --- a/modules/companies/src/api/infrastructure/http/company.routes.ts +++ b/modules/companies/src/api/infrastructure/http/company.routes.ts @@ -127,6 +127,25 @@ export function registerCompanyRoutes( post("/:companyId/sites/:id/update", SiteRequestSchema, ChildParamsSchema, (dto, params) => useCases.updateSite(params.companyId, params.id, dto) ); + router.post("/:companyId/sites/:id/delete", (request, response, next) => { + const params = ChildParamsSchema.safeParse(request.params); + + if ( + !( + params.success && + z.strictObject({}).safeParse(request.body).success && + z.strictObject({}).safeParse(request.query).success + ) + ) { + sendError(response, "INVALID_REQUEST", 400); + + return; + } + useCases + .removeSite(params.data.companyId, params.data.id) + .then((result) => sendResult(response, result)) + .catch(next); + }); post( "/:companyId/people", PersonRequestSchema, diff --git a/modules/companies/src/api/infrastructure/persistence/company-persistence.mapper.ts b/modules/companies/src/api/infrastructure/persistence/company-persistence.mapper.ts index b1d7481..3792102 100644 --- a/modules/companies/src/api/infrastructure/persistence/company-persistence.mapper.ts +++ b/modules/companies/src/api/infrastructure/persistence/company-persistence.mapper.ts @@ -173,6 +173,7 @@ export class CompanyPersistenceMapper { id: site.id.toPrimitive(), companyId: site.companyId.toPrimitive(), name: site.name.toPrimitive(), + active: site.active, address: site.address.getOrUndefined()?.toPrimitive() ?? null, phone: site.phone.getOrUndefined()?.toPrimitive() ?? null, })), diff --git a/modules/companies/src/api/infrastructure/persistence/company.model.ts b/modules/companies/src/api/infrastructure/persistence/company.model.ts index 9ca2af2..dbd5ea4 100644 --- a/modules/companies/src/api/infrastructure/persistence/company.model.ts +++ b/modules/companies/src/api/infrastructure/persistence/company.model.ts @@ -38,6 +38,7 @@ export class SiteModel extends Model< declare id: string; declare companyId: string; declare name: string; + declare active: boolean; declare address: string | null; declare phone: string | null; } @@ -73,6 +74,7 @@ export function initializeCompanyModels(database: Sequelize): void { SiteModel.init( { ...childFields, + active: { type: DataTypes.BOOLEAN, allowNull: false }, address: { type: DataTypes.TEXT, allowNull: true }, phone: { type: DataTypes.STRING(50), allowNull: true }, }, diff --git a/modules/companies/src/api/infrastructure/persistence/migrate-companies.ts b/modules/companies/src/api/infrastructure/persistence/migrate-companies.ts index 32e67a4..bf9df6d 100644 --- a/modules/companies/src/api/infrastructure/persistence/migrate-companies.ts +++ b/modules/companies/src/api/infrastructure/persistence/migrate-companies.ts @@ -39,6 +39,13 @@ export async function migrateCompanies(database: Sequelize): Promise { if (tables.includes("company_sites")) { const columns = await query.describeTable("company_sites"); + if (!columns.active) { + await query.addColumn("company_sites", "active", { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true, + }); + } if (!columns.phone) { await query.addColumn("company_sites", "phone", { type: DataTypes.STRING(50), @@ -48,6 +55,7 @@ export async function migrateCompanies(database: Sequelize): Promise { } else { await query.createTable("company_sites", { ...childFields, + active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true }, address: { type: DataTypes.TEXT, allowNull: true }, phone: { type: DataTypes.STRING(50), allowNull: true }, }); diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize-company.repository.ts b/modules/companies/src/api/infrastructure/persistence/sequelize-company.repository.ts index f1dcf3d..faf1e2b 100644 --- a/modules/companies/src/api/infrastructure/persistence/sequelize-company.repository.ts +++ b/modules/companies/src/api/infrastructure/persistence/sequelize-company.repository.ts @@ -2,7 +2,7 @@ import type { UniqueID } from "@repo/rdx-ddd"; import type { Result as DomainResult } from "@repo/rdx-utils"; import { Maybe, Result } from "@repo/rdx-utils"; import type { Sequelize, Transaction } from "sequelize"; -import { Op } from "sequelize"; +import { ForeignKeyConstraintError, Op } from "sequelize"; import type { ICompanyRepository } from "../../application/company.repository"; import type { Company } from "../../domain/company.aggregate"; @@ -116,6 +116,35 @@ export class SequelizeCompanyRepository implements ICompanyRepository { return company; } + async removeSite(id: UniqueID, siteId: UniqueID): Promise> { + return this.database.transaction(async (transaction) => { + const current = (await this.load(id, transaction, true)).getOrUndefined(); + + if (!current) { + return Result.fail(new CompanyError("COMPANY_NOT_FOUND")); + } + const changed = current.removeSite(siteId); + + if (!changed.isSuccess) { + return changed; + } + try { + // Las FK RESTRICT incluyen referencias históricas y evitan carreras con nuevas asociaciones. + await SiteModel.destroy({ + where: { id: siteId.toPrimitive(), companyId: id.toPrimitive() }, + transaction, + }); + } catch (error) { + if (error instanceof ForeignKeyConstraintError) { + return Result.fail(new CompanyError("SITE_IN_USE")); + } + throw error; + } + + return changed; + }); + } + async removePerson( id: UniqueID, personId: UniqueID @@ -200,7 +229,7 @@ export class SequelizeCompanyRepository implements ICompanyRepository { if (record.sites.length) { await SiteModel.bulkCreate(record.sites, { transaction, - updateOnDuplicate: ["name", "address", "phone"], + updateOnDuplicate: ["name", "active", "address", "phone"], }); } if (record.departments.length) { diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize-remote-connection.repository.ts b/modules/companies/src/api/infrastructure/persistence/sequelize-remote-connection.repository.ts index b0e76e5..33db492 100644 --- a/modules/companies/src/api/infrastructure/persistence/sequelize-remote-connection.repository.ts +++ b/modules/companies/src/api/infrastructure/persistence/sequelize-remote-connection.repository.ts @@ -57,8 +57,8 @@ export class SequelizeRemoteConnectionRepository implements IRemoteConnectionRep "SELECT id,active FROM company_contacts WHERE companyId=:companyId", { replacements: { companyId }, transaction, type: QueryTypes.SELECT } ); - const sites = await this.database.query<{ id: string }>( - "SELECT id FROM company_sites WHERE companyId=:companyId", + const sites = await this.database.query<{ id: string; active: boolean }>( + "SELECT id,active FROM company_sites WHERE companyId=:companyId", { replacements: { companyId }, transaction, type: QueryTypes.SELECT } ); const authorizations = await this.database.query( @@ -87,7 +87,7 @@ export class SequelizeRemoteConnectionRepository implements IRemoteConnectionRep ) ), people: people.map((p) => ({ id: teleworkIdentity(p.id), active: Boolean(p.active) })), - sites: sites.map((s) => teleworkIdentity(s.id)), + sites: sites.map((s) => ({ id: teleworkIdentity(s.id), active: Boolean(s.active) })), assets: await this.inventory.list(company), resources: await this.resources.list(company), actors: new Map(actors.map((a) => [a.id, a.name])), diff --git a/modules/companies/src/common/dto/response/company.response.dto.ts b/modules/companies/src/common/dto/response/company.response.dto.ts index 13c134a..96800ce 100644 --- a/modules/companies/src/common/dto/response/company.response.dto.ts +++ b/modules/companies/src/common/dto/response/company.response.dto.ts @@ -15,6 +15,7 @@ export const CompanyErrorResponseSchema = z.strictObject({ "COMPANY_NOT_FOUND", "CONTACT_NOT_FOUND", "SITE_NOT_FOUND", + "SITE_IN_USE", "PERSON_NOT_FOUND", "DEPARTMENT_NOT_FOUND", "DEPARTMENT_NAME_EXISTS", diff --git a/modules/companies/src/common/dto/shared/company.dto.ts b/modules/companies/src/common/dto/shared/company.dto.ts index 7980a81..b3e7aa4 100644 --- a/modules/companies/src/common/dto/shared/company.dto.ts +++ b/modules/companies/src/common/dto/shared/company.dto.ts @@ -23,6 +23,7 @@ export const CompanyFieldsSchema = z.strictObject({ export const ContactFieldsSchema = PersonFieldsSchema.omit({ isContact: true }); export const SiteFieldsSchema = z.strictObject({ + active: z.boolean(), name: RequiredTextSchema, address: z.string().max(10000).nullable(), phone: z.string().max(50).nullable(), diff --git a/modules/companies/src/common/locales/en.json b/modules/companies/src/common/locales/en.json index d2f0b65..15c4811 100644 --- a/modules/companies/src/common/locales/en.json +++ b/modules/companies/src/common/locales/en.json @@ -690,7 +690,8 @@ "INTERNAL_ERROR": "The operation could not be completed. Try again.", "INVALID_LOGO": "Invalid image. Use a non-animated PNG, JPEG or WebP.", "LOGO_TOO_LARGE": "Logo exceeds the limit: maximum 2 MB and 4096 × 4096 px.", - "LOGO_NOT_FOUND": "The logo is no longer available." + "LOGO_NOT_FOUND": "The logo is no longer available.", + "SITE_IN_USE": "The site has associated records or historical references and cannot be deleted. You can archive it." }, "shell": { "catalogs": "Catalogs" @@ -767,7 +768,17 @@ "licenseHelp": "Informational reference to a license belonging to this company. It does not reserve seats or confirm coverage for this product or device. Leave it empty for free software or when the license is not yet known.", "licenseUnavailable": "Unavailable" }, - "companyLabel": "Company" + "companyLabel": "Company", + "siteLifecycle": { + "active": "Active site", + "help": "Archiving a site preserves its associations and history. It is no longer available for new assignments and can be restored.", + "archive": "Archive", + "restore": "Restore", + "archived": "Archived", + "showArchived": "Also show archived sites", + "delete": "Delete site", + "deleteDescription": "“{{name}}” will be permanently deleted. This is only allowed if it has no associated records, including historical references. If it is in use, you can archive it." + } }, "access": { "helpTitle": "What is it and what is it for?", diff --git a/modules/companies/src/common/locales/es.json b/modules/companies/src/common/locales/es.json index 083a8c2..3acaea3 100644 --- a/modules/companies/src/common/locales/es.json +++ b/modules/companies/src/common/locales/es.json @@ -690,7 +690,8 @@ "INTERNAL_ERROR": "No se ha podido completar la operación. Inténtalo de nuevo.", "INVALID_LOGO": "La imagen no es válida. Usa PNG, JPEG o WebP sin animación.", "LOGO_TOO_LARGE": "El logotipo supera el límite: máximo 2 MB y 4096 × 4096 px.", - "LOGO_NOT_FOUND": "El logotipo ya no está disponible." + "LOGO_NOT_FOUND": "El logotipo ya no está disponible.", + "SITE_IN_USE": "La sede tiene registros asociados o referencias históricas y no puede eliminarse. Puedes archivarla." }, "shell": { "catalogs": "Catálogos" @@ -767,7 +768,17 @@ "licenseHelp": "Referencia informativa a una licencia de esta compañía. No reserva plazas ni confirma que la licencia cubra este producto o equipo. Puedes dejarla vacía para software gratuito o si aún no se conoce.", "licenseUnavailable": "No disponible" }, - "companyLabel": "Compañía" + "companyLabel": "Compañía", + "siteLifecycle": { + "active": "Sede activa", + "help": "Al archivar una sede se conservan sus asociaciones e historial. Deja de estar disponible para nuevas asignaciones y puede reactivarse.", + "archive": "Archivar", + "restore": "Reactivar", + "archived": "Archivada", + "showArchived": "Mostrar también las sedes archivadas", + "delete": "Eliminar sede", + "deleteDescription": "Se eliminará «{{name}}» de forma permanente. Solo se permite si no tiene ningún registro asociado, incluidas referencias históricas. Si está en uso, puedes archivarla." + } }, "access": { "helpTitle": "¿Qué es y para qué sirve?", diff --git a/modules/companies/src/web/companies/shared/companies-page.test.tsx b/modules/companies/src/web/companies/shared/companies-page.test.tsx index f864b43..1597efb 100644 --- a/modules/companies/src/web/companies/shared/companies-page.test.tsx +++ b/modules/companies/src/web/companies/shared/companies-page.test.tsx @@ -14,6 +14,7 @@ import { DashboardPage, dashboardKeys } from "../../dashboard"; import { PersonForm } from "../../people"; import { companiesEnglish, companiesSpanish } from "../../shared"; import { SiteForm } from "../../sites"; +import { SiteSection } from "../../sites/list"; import { CatalogOverview } from "../../workspace/catalogs/ui/catalog-overview"; import { CompanyDetail } from "../detail"; import { CompanyOverview } from "../detail/ui/company-overview"; @@ -341,3 +342,63 @@ test("el menú de compañía ordena trabajo y personas por grupos sin repetir la assert.equal(html.includes("Mi contraseña"), false); assert.ok(html.includes('data-slot="avatar-badge"')); }); + +test("sedes: listado traducido con filtro de archivadas y solo sedes activas por defecto", async () => { + const company = { + id: companyId, + name: "Compañía", + active: true, + contacts: [], + people: [], + departments: [], + assignments: [], + sites: [ + { + id: companyId, + companyId, + name: "Central activa", + active: true, + address: null, + phone: null, + }, + { + id: "0199b200-0000-7000-8000-000000000002", + companyId, + name: "Sede archivada test", + active: false, + address: null, + phone: null, + }, + ], + }; + + const archived = company.sites.find((site) => !site.active); + + assert.ok(archived); + for (const language of ["es", "en"]) { + const markup = await renderPage(language, , () => undefined); + + assert.ok(markup.includes("Central activa")); + assert.equal(markup.includes("Sede archivada test"), false); + assert.ok( + markup.includes( + language === "es" ? "Mostrar también las sedes archivadas" : "Also show archived sites" + ) + ); + const form = await renderPage( + language, + , + () => undefined + ); + + assert.ok(form.includes(language === "es" ? "Sede activa" : "Active site")); + assert.ok( + form.includes( + language === "es" + ? "se conservan sus asociaciones e historial" + : "preserves its associations and history" + ) + ); + assert.ok(form.includes('aria-checked="false"')); + } +}); diff --git a/modules/companies/src/web/remote-connections/shared/ui/remote-target-field.tsx b/modules/companies/src/web/remote-connections/shared/ui/remote-target-field.tsx index 0a3b143..8807670 100644 --- a/modules/companies/src/web/remote-connections/shared/ui/remote-target-field.tsx +++ b/modules/companies/src/web/remote-connections/shared/ui/remote-target-field.tsx @@ -39,7 +39,9 @@ export function RemoteTargetField({ const targetOptions = { network: [ { value: "", label: t("remote.allCompany") }, - ...company.sites.map((s) => ({ value: s.id, label: s.name })), + ...company.sites + .filter((site) => site.active || (fixed && site.id === v.siteId)) + .map((s) => ({ value: s.id, label: s.name })), ], asset: assetOptions.filter((a) => a.value !== v.sourceAssetId), resource: resourceOptions, diff --git a/modules/companies/src/web/shared/client-contracts.test.ts b/modules/companies/src/web/shared/client-contracts.test.ts index c5f5dd8..bf24cb6 100644 --- a/modules/companies/src/web/shared/client-contracts.test.ts +++ b/modules/companies/src/web/shared/client-contracts.test.ts @@ -101,12 +101,17 @@ test("los adapters mantienen la ausencia semántica sin modificar los campos del assert.equal(request.phone, null); assert.equal(request.active, false); assert.equal(person.email, " ana@example.test "); - assert.deepEqual(buildSiteRequest({ name: "Office", address: " ", phone: " " }), { - name: "Office", - address: null, - phone: null, - }); + assert.deepEqual( + buildSiteRequest({ active: true, name: "Office", address: " ", phone: " " }), + { + active: true, + name: "Office", + address: null, + phone: null, + } + ); const site = buildSiteRequest({ + active: true, name: "Office", address: " Street\n Floor ", phone: " +34 900 123 456 ", diff --git a/modules/companies/src/web/sites/delete/api/delete-site.api.ts b/modules/companies/src/web/sites/delete/api/delete-site.api.ts new file mode 100644 index 0000000..e7e06e4 --- /dev/null +++ b/modules/companies/src/web/sites/delete/api/delete-site.api.ts @@ -0,0 +1,5 @@ +import { CompanyResponseSchema } from "../../../../common"; +import { companyRequest } from "../../../companies/shared/api/company-request"; + +export const deleteSite = (companyId: string, id: string) => + companyRequest(`/${companyId}/sites/${id}/delete`, CompanyResponseSchema, {}); diff --git a/modules/companies/src/web/sites/delete/ui/site-delete-dialog.tsx b/modules/companies/src/web/sites/delete/ui/site-delete-dialog.tsx new file mode 100644 index 0000000..03ce7bc --- /dev/null +++ b/modules/companies/src/web/sites/delete/ui/site-delete-dialog.tsx @@ -0,0 +1,37 @@ +import { ConfirmDeleteDialog } from "@repo/rdx-ui/components/confirm-delete-dialog"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; + +import type { SiteDTO } from "../../../../common"; +import { CompanyApiError } from "../../../companies/shared/api"; +import { companyKeys } from "../../../companies/shared/queries"; +import { deleteSite } from "../api/delete-site.api"; + +export function SiteDeleteDialog({ site, onClose }: { site: SiteDTO; onClose: () => void }) { + const { t } = useTranslation("companies"); + const client = useQueryClient(); + const mutation = useMutation({ + mutationFn: () => deleteSite(site.companyId, site.id), + onSuccess: (response) => client.setQueryData(companyKeys.detail(site.companyId), response), + }); + + return ( + mutation.mutateAsync()} + onOpenChange={(open) => { + if (!open) { + onClose(); + } + }} + open + pendingLabel={t("deleteRecord.pending")} + title={t("siteLifecycle.delete")} + /> + ); +} diff --git a/modules/companies/src/web/sites/list/controllers/use-site-section-controller.ts b/modules/companies/src/web/sites/list/controllers/use-site-section-controller.ts new file mode 100644 index 0000000..11061d5 --- /dev/null +++ b/modules/companies/src/web/sites/list/controllers/use-site-section-controller.ts @@ -0,0 +1,37 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; + +import type { CompanyDTO, SiteDTO } from "../../../../common"; +import { companyKeys } from "../../../companies/shared/queries"; +import { saveSite } from "../../shared/api"; + +export function useSiteSectionController(company: CompanyDTO) { + const client = useQueryClient(); + const [createOpen, setCreateOpen] = useState(false); + const [selectedId, setSelectedId] = useState(); + const [deletingId, setDeletingId] = useState(); + const [showArchived, setShowArchived] = useState(false); + const mutation = useMutation({ + mutationFn: (site: SiteDTO) => + saveSite(company.id, site.id, { + name: site.name, + address: site.address, + phone: site.phone, + active: !site.active, + }), + onSuccess: (response) => client.setQueryData(companyKeys.detail(company.id), response), + }); + + return { + createOpen, + setCreateOpen, + setSelectedId, + setDeletingId, + showArchived, + setShowArchived, + mutation, + selected: company.sites.find((site) => site.id === selectedId), + deleting: company.sites.find((site) => site.id === deletingId), + sites: company.sites.filter((site) => showArchived || site.active), + }; +} diff --git a/modules/companies/src/web/sites/list/ui/site-section.tsx b/modules/companies/src/web/sites/list/ui/site-section.tsx index 1ef67dc..a8b9881 100644 --- a/modules/companies/src/web/sites/list/ui/site-section.tsx +++ b/modules/companies/src/web/sites/list/ui/site-section.tsx @@ -2,20 +2,36 @@ import { EntityAction, EntityListHeader, EntityRowActions, + EntityStatus, EntityTable, } from "@repo/rdx-ui/components/entity-list"; +import { FormLabel } from "@repo/rdx-ui/components/form-label"; import { Button } from "@repo/shadcn-ui/components/button"; -import { useState } from "react"; +import { Checkbox } from "@repo/shadcn-ui/components/checkbox"; +import { useId } from "react"; import { useTranslation } from "react-i18next"; import type { CompanyDTO } from "../../../../common"; +import { CompanyFeedback } from "../../../companies/shared"; +import { SiteDeleteDialog } from "../../delete/ui/site-delete-dialog"; import { SiteEditorDialog } from "../../shared"; +import { useSiteSectionController } from "../controllers/use-site-section-controller"; export function SiteSection({ company }: { company: CompanyDTO }) { const { t, i18n } = useTranslation("companies"); - const [createOpen, setCreateOpen] = useState(false); - const [selectedId, setSelectedId] = useState(); - const selected = company.sites.find((site) => site.id === selectedId); + const filterId = useId(); + const { + createOpen, + setCreateOpen, + setSelectedId, + deleting, + setDeletingId, + showArchived, + setShowArchived, + mutation, + selected, + sites, + } = useSiteSectionController(company); return (
@@ -24,6 +40,7 @@ export function SiteSection({ company }: { company: CompanyDTO }) { locale={i18n.language} title={t("sites")} /> + {site.name}, }, + { + id: "state", + label: t("status"), + value: (site) => t(site.active ? "active" : "siteLifecycle.archived"), + render: (site) => ( + + {t(site.active ? "active" : "siteLifecycle.archived")} + + ), + }, { id: "address", label: t("addressOptional"), @@ -52,16 +79,45 @@ export function SiteSection({ company }: { company: CompanyDTO }) { hideable: false, render: (site) => ( - setSelectedId(site.id)}>{t("edit")} + setSelectedId(site.id)}> + {t("edit")} + + mutation.mutate(site)}> + {t(site.active ? "siteLifecycle.archive" : "siteLifecycle.restore")} + + setDeletingId(site.id)} + > + {t("siteLifecycle.delete")} + ), }, ]} emptyMessage={t("noSites")} - items={company.sites} + filters={ + + setShowArchived(checked === true)} + /> + {t("siteLifecycle.showArchived")} + + } + items={sites} locale={i18n.language} title={t("sites")} /> + {deleting && ( + setDeletingId(undefined)} + site={deleting} + /> + )} mutation.mutate(values)); + const submit = form.handleSubmit((values) => { + if (!mutation.isPending) { + mutation.mutate(values); + } + }); return { form, mutation, submit }; } diff --git a/modules/companies/src/web/sites/shared/ui/site-form.tsx b/modules/companies/src/web/sites/shared/ui/site-form.tsx index 7fe9d46..1fabea1 100644 --- a/modules/companies/src/web/sites/shared/ui/site-form.tsx +++ b/modules/companies/src/web/sites/shared/ui/site-form.tsx @@ -1,5 +1,6 @@ import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog"; import { FormLabel } from "@repo/rdx-ui/components/form-label"; +import { SwitchField } from "@repo/rdx-ui/components/switch-field"; import { Button } from "@repo/shadcn-ui/components/button"; import { Input } from "@repo/shadcn-ui/components/input"; import { Textarea } from "@repo/shadcn-ui/components/textarea"; @@ -21,27 +22,43 @@ export function SiteForm(props: SiteFormProps) {
{showTitle &&

{t(site ? "editSite" : "newSite")}

} -
- - {t("name")} - - -
-
- {t("addressOptional")} -