From 03c1c30146bb765373dd6986f08111981e7cd111 Mon Sep 17 00:00:00 2001 From: David Date: Mon, 5 Oct 2026 13:13:51 +0200 Subject: [PATCH] Incorporar recursos --- README.md | 1 + apps/server/package.json | 3 +- apps/server/src/identity.test.ts | 15 + .../infrastructure/http/identity-runtime.ts | 3 + apps/server/src/resources.integration.ts | 257 +++++++++++++++ apps/web/package.json | 5 +- apps/web/src/i18n.ts | 3 + .../modelo-ddd-e-identificadores.md | 2 + docs/plan-de-desarrollo.md | 11 +- docs/recursos-campos-y-plantillas.md | 62 ++++ modules/companies/package.json | 3 +- .../web/components/companies-workspace.tsx | 32 +- .../src/web/components/company-detail.tsx | 19 +- modules/resources/package.json | 39 +++ .../resource-catalog-response.builder.ts | 31 ++ .../resource-catalog.repository.ts | 12 + .../application/resource-catalog.use-cases.ts | 55 ++++ .../api/application/resource-input.mapper.ts | 60 ++++ .../application/resource-response.builder.ts | 20 ++ .../api/application/resource.repository.ts | 21 ++ .../src/api/application/resource.use-cases.ts | 60 ++++ .../src/api/domain/resource-catalog-seed.ts | 55 ++++ .../api/domain/resource-catalog.aggregate.ts | 184 +++++++++++ .../src/api/domain/resource-catalog.test.ts | 112 +++++++ .../api/domain/resource-definition.entity.ts | 133 ++++++++ .../src/api/domain/resource-definition.ts | 28 ++ .../api/domain/resource-revision.entity.ts | 25 ++ .../src/api/domain/resource-values.ts | 32 ++ .../src/api/domain/resource.aggregate.ts | 70 ++++ .../resources/src/api/domain/resource.test.ts | 108 +++++++ .../resource-definition-values.ts | 97 ++++++ .../domain/value-objects/resource-values.ts | 182 +++++++++++ .../http/resource-catalog.routes.ts | 109 +++++++ .../infrastructure/http/resource.routes.ts | 75 +++++ .../resource-catalog-persistence.mapper.ts | 133 ++++++++ .../persistence/resource-catalog.model.ts | 125 ++++++++ .../resource-persistence.mapper.ts | 92 ++++++ .../persistence/resource.model.ts | 74 +++++ .../sequelize-resource-catalog.repository.ts | 95 ++++++ .../sequelize-resource.repository.ts | 136 ++++++++ modules/resources/src/api/module.ts | 45 +++ modules/resources/src/common/index.ts | 30 ++ modules/resources/src/common/inventory.ts | 34 ++ modules/resources/src/web/catalog-api.ts | 42 +++ .../src/web/catalog-definition-dialog.tsx | 85 +++++ modules/resources/src/web/catalog-form.tsx | 166 ++++++++++ modules/resources/src/web/catalog-help.tsx | 15 + modules/resources/src/web/catalog-page.tsx | 303 ++++++++++++++++++ .../src/web/catalog-template-form.tsx | 198 ++++++++++++ modules/resources/src/web/catalog.test.tsx | 71 ++++ modules/resources/src/web/i18n.ts | 200 ++++++++++++ modules/resources/src/web/index.ts | 3 + modules/resources/src/web/inventory-api.ts | 37 +++ modules/resources/src/web/inventory-i18n.ts | 81 +++++ .../web/resource-characteristic-control.tsx | 85 +++++ modules/resources/src/web/resource-detail.tsx | 74 +++++ modules/resources/src/web/resource-form.tsx | 242 ++++++++++++++ modules/resources/src/web/resources-page.tsx | 140 ++++++++ modules/resources/src/web/resources.test.tsx | 77 +++++ modules/resources/tsconfig.json | 9 + pnpm-lock.yaml | 67 ++++ 61 files changed, 4568 insertions(+), 15 deletions(-) create mode 100644 apps/server/src/resources.integration.ts create mode 100644 docs/recursos-campos-y-plantillas.md create mode 100644 modules/resources/package.json create mode 100644 modules/resources/src/api/application/resource-catalog-response.builder.ts create mode 100644 modules/resources/src/api/application/resource-catalog.repository.ts create mode 100644 modules/resources/src/api/application/resource-catalog.use-cases.ts create mode 100644 modules/resources/src/api/application/resource-input.mapper.ts create mode 100644 modules/resources/src/api/application/resource-response.builder.ts create mode 100644 modules/resources/src/api/application/resource.repository.ts create mode 100644 modules/resources/src/api/application/resource.use-cases.ts create mode 100644 modules/resources/src/api/domain/resource-catalog-seed.ts create mode 100644 modules/resources/src/api/domain/resource-catalog.aggregate.ts create mode 100644 modules/resources/src/api/domain/resource-catalog.test.ts create mode 100644 modules/resources/src/api/domain/resource-definition.entity.ts create mode 100644 modules/resources/src/api/domain/resource-definition.ts create mode 100644 modules/resources/src/api/domain/resource-revision.entity.ts create mode 100644 modules/resources/src/api/domain/resource-values.ts create mode 100644 modules/resources/src/api/domain/resource.aggregate.ts create mode 100644 modules/resources/src/api/domain/resource.test.ts create mode 100644 modules/resources/src/api/domain/value-objects/resource-definition-values.ts create mode 100644 modules/resources/src/api/domain/value-objects/resource-values.ts create mode 100644 modules/resources/src/api/infrastructure/http/resource-catalog.routes.ts create mode 100644 modules/resources/src/api/infrastructure/http/resource.routes.ts create mode 100644 modules/resources/src/api/infrastructure/persistence/resource-catalog-persistence.mapper.ts create mode 100644 modules/resources/src/api/infrastructure/persistence/resource-catalog.model.ts create mode 100644 modules/resources/src/api/infrastructure/persistence/resource-persistence.mapper.ts create mode 100644 modules/resources/src/api/infrastructure/persistence/resource.model.ts create mode 100644 modules/resources/src/api/infrastructure/persistence/sequelize-resource-catalog.repository.ts create mode 100644 modules/resources/src/api/infrastructure/persistence/sequelize-resource.repository.ts create mode 100644 modules/resources/src/api/module.ts create mode 100644 modules/resources/src/common/index.ts create mode 100644 modules/resources/src/common/inventory.ts create mode 100644 modules/resources/src/web/catalog-api.ts create mode 100644 modules/resources/src/web/catalog-definition-dialog.tsx create mode 100644 modules/resources/src/web/catalog-form.tsx create mode 100644 modules/resources/src/web/catalog-help.tsx create mode 100644 modules/resources/src/web/catalog-page.tsx create mode 100644 modules/resources/src/web/catalog-template-form.tsx create mode 100644 modules/resources/src/web/catalog.test.tsx create mode 100644 modules/resources/src/web/i18n.ts create mode 100644 modules/resources/src/web/index.ts create mode 100644 modules/resources/src/web/inventory-api.ts create mode 100644 modules/resources/src/web/inventory-i18n.ts create mode 100644 modules/resources/src/web/resource-characteristic-control.tsx create mode 100644 modules/resources/src/web/resource-detail.tsx create mode 100644 modules/resources/src/web/resource-form.tsx create mode 100644 modules/resources/src/web/resources-page.tsx create mode 100644 modules/resources/src/web/resources.test.tsx create mode 100644 modules/resources/tsconfig.json diff --git a/README.md b/README.md index 3702612..d9421a9 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Aplicación interna para soporte técnico a pequeñas empresas. - [Compañías, contactos y sedes](docs/companias-contactos-y-sedes.md) - [Personas y departamentos](docs/personas-y-departamentos.md) - [Cuentas, grupos y recursos](docs/cuentas-grupos-y-recursos.md) +- [Recursos, campos y plantillas](docs/recursos-campos-y-plantillas.md) - [Inventario de activos](docs/inventario-de-activos.md) - [Puestos de trabajo](docs/puestos-de-trabajo.md) - [Perfiles de trabajo](docs/perfiles-de-trabajo.md) diff --git a/apps/server/package.json b/apps/server/package.json index c952129..9b191c5 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -21,7 +21,8 @@ "express-session": "1.19.0", "connect-session-sequelize": "8.0.6", "@metis/companies": "workspace:*", - "@metis/assets": "workspace:*" + "@metis/assets": "workspace:*", + "@metis/resources": "workspace:*" }, "devDependencies": { "@repo/typescript-config": "workspace:*", diff --git a/apps/server/src/identity.test.ts b/apps/server/src/identity.test.ts index 1e0237c..a7571e6 100644 --- a/apps/server/src/identity.test.ts +++ b/apps/server/src/identity.test.ts @@ -15,6 +15,7 @@ import { UserResponseSchema, UsersResponseSchema, } from "@metis/identity/common"; +import { createResourceModule } from "@metis/resources/module"; import { QueryTypes } from "sequelize"; import { verifyAccess } from "./access.integration"; @@ -25,6 +26,7 @@ import { createIdentityRuntime } from "./infrastructure/http/identity-runtime"; import { verifyInventory } from "./inventory.integration"; import { verifyMail } from "./mail.integration"; import { verifyRelationships } from "./relationships.integration"; +import { verifyResources } from "./resources.integration"; import { verifyWorkProfiles } from "./work-profiles.integration"; import { verifyWorkstations } from "./workstations.integration"; @@ -52,6 +54,11 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de await database.query("DELETE FROM workstation_assets"); await database.query("DELETE FROM workstation_revisions"); await database.query("DELETE FROM workstations"); + await database.query("DELETE FROM resource_revisions"); + await database.query("DELETE FROM resources"); + await database.query("DELETE FROM resource_availability"); + await database.query("DELETE FROM resource_template_fields"); + await database.query("DELETE FROM resource_definitions"); await database.query("DELETE FROM asset_relationships"); await database.query("DELETE FROM asset_revisions"); await database.query("DELETE FROM assets"); @@ -72,6 +79,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de await database.query("DELETE FROM company_sites"); await database.query("DELETE FROM companies"); await createAssetModule(database, (_request, _response, next) => next()); + await createResourceModule(database, (_request, _response, next) => next()); // Simular la tabla de contactos de la versión anterior solo en la base de pruebas. const legacyCompanyId = "48f13545-1529-4ba1-a353-a5f844141411"; const legacyPersonId = "4ec8f8c5-3ad9-4e22-abf3-b95370400898"; @@ -150,6 +158,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de await competitor.session(); await expectError(await admin.request("/users"), 401, "UNAUTHENTICATED"); await expectError(await admin.request("/asset-catalog/global"), 401, "UNAUTHENTICATED"); + await expectError(await admin.request("/resource-catalog/global"), 401, "UNAUTHENTICATED"); const first = { name: "Admin test", email: "ADMIN@example.test", @@ -705,6 +714,12 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de secondCompany.id, database ); + await verifyResources( + (...args) => operator.request(...args), + customer.id, + secondCompany.id, + database + ); await verifyInventory( (...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 d0e8122..340e3ec 100644 --- a/apps/server/src/infrastructure/http/identity-runtime.ts +++ b/apps/server/src/infrastructure/http/identity-runtime.ts @@ -1,6 +1,7 @@ import { createAssetModule } from "@metis/assets/module"; import { createCompanyModule } from "@metis/companies/module"; import { createIdentityModule, migrateIdentity } from "@metis/identity/module"; +import { createResourceModule } from "@metis/resources/module"; import connectSequelize from "connect-session-sequelize"; import session from "express-session"; import type { Sequelize } from "sequelize"; @@ -16,6 +17,7 @@ export async function createIdentityRuntime( const identity = await createIdentityModule(database, environment.APP_ORIGIN); const companies = await createCompanyModule(database, identity.authenticate); 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 SequelizeStore = connectSequelize(session.Store); @@ -42,6 +44,7 @@ export async function createIdentityRuntime( identity.register(app); companies.register(app); assets.register(app); + resources.register(app); mail.register(app); profiles.register(app); }, diff --git a/apps/server/src/resources.integration.ts b/apps/server/src/resources.integration.ts new file mode 100644 index 0000000..96e305d --- /dev/null +++ b/apps/server/src/resources.integration.ts @@ -0,0 +1,257 @@ +import assert from "node:assert/strict"; + +import { CatalogResponseSchema as AssetCatalogResponseSchema } from "@metis/assets/common"; +import { + CatalogResponseSchema, + InventoryResponseSchema, + ResourceResponseSchema, + SaveDefinitionResponseSchema, +} from "@metis/resources/common"; +import { createResourceModule } from "@metis/resources/module"; +import type { Sequelize } from "sequelize"; +import { QueryTypes } from "sequelize"; +import { z } from "zod/v4"; + +export async function verifyResources( + request: (path: string, body?: unknown, origin?: string, token?: string) => Promise, + companyId: string, + otherId: string, + database: Sequelize +) { + const global = "/resource-catalog/global"; + const company = `/resource-catalog/${companyId}`; + const other = `/resource-catalog/${otherId}`; + const inventory = `/resources/${companyId}`; + async function read(path: string) { + const response = await request(path); + assert.equal(response.status, 200); + return CatalogResponseSchema.parse(await response.json()); + } + async function save(path: string, values: unknown) { + const response = await request(path, { values }); + assert.ok([200, 201].includes(response.status), await response.clone().text()); + return SaveDefinitionResponseSchema.parse(await response.json()); + } + async function error(path: string, body: unknown, code: string, status = 409) { + const response = await request(path, body); + assert.equal(response.status, status); + assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code); + } + async function resource(path: string, values: unknown) { + const response = await request(path, { values }); + assert.ok([200, 201].includes(response.status), await response.clone().text()); + return ResourceResponseSchema.parse(await response.json()).resource; + } + const initial = await read(global); + assert.equal(initial.items.length, 16); + assert.equal(initial.items.filter((item) => item.values.kind === "template").length, 4); + assert.ok(initial.items.every((item) => item.builtin && item.id[14] === "7")); + assert.ok( + initial.items.every((item) => + item.bindings.every((binding) => !binding.required && binding.id[14] === "7") + ) + ); + const assetsResponse = await request("/asset-catalog/global"); + const assets = AssetCatalogResponseSchema.parse(await assetsResponse.json()); + assert.ok(initial.items.every((item) => !assets.items.some((asset) => asset.id === item.id))); + const stock = initial.items.find((item) => item.values.name === "Dirección de correo"); + const stockTemplate = initial.items.find((item) => item.values.name === "Buzón de correo"); + assert.ok(stock && stockTemplate && stockTemplate.values.kind === "template"); + await error( + `${global}/${stock.id}/update`, + { values: { ...stock.values, name: "Cambio" } }, + "BUILTIN_PROTECTED" + ); + assert.equal( + (await request(`${global}/${stock.id}/status`, { active: false }, "https://other.example")) + .status, + 403 + ); + assert.equal( + (await request(`${global}/${stock.id}/status`, { active: false }, undefined, "a".repeat(64))) + .status, + 403 + ); + assert.equal((await request(`${company}/${stock.id}/status`, { active: false })).status, 200); + assert.equal( + (await read(other)).items.find((item) => item.id === stock.id)?.effectiveActive, + true + ); + await createResourceModule(database, (_request, _response, next) => next()); + const restarted = await read(global); + assert.deepEqual( + restarted.items.map((item) => item.id).sort(), + initial.items.map((item) => item.id).sort() + ); + assert.equal( + (await read(company)).items.find((item) => item.id === stock.id)?.effectiveActive, + false + ); + assert.equal((await request(`${company}/${stock.id}/status`, { active: null })).status, 200); + const definition = { + kind: "field", + name: "Marca", + type: "text", + unit: "", + description: "", + choices: [], + }; + const local = await save(company, definition); + const fieldId = local.savedId; + assert.ok(!(await read(other)).items.some((item) => item.id === fieldId)); + const assetField = assets.items.find((item) => item.values.kind === "field"); + const assetTemplate = assets.items.find((item) => item.values.kind === "template"); + assert.ok(assetField && assetTemplate); + const template = { + kind: "template", + name: "Recurso propio", + description: "", + fields: [{ fieldId, required: true }], + }; + await error( + company, + { values: { ...template, fields: [{ fieldId: assetField.id, required: false }] } }, + "FIELD_UNAVAILABLE" + ); + await error( + `/asset-catalog/${companyId}`, + { values: { ...template, fields: [{ fieldId: stock.id, required: false }] } }, + "FIELD_UNAVAILABLE" + ); + await error(other, { values: template }, "FIELD_UNAVAILABLE"); + await error(global, { values: template }, "FIELD_UNAVAILABLE"); + const createdTemplate = await save(company, template); + await error( + `${other}/${createdTemplate.savedId}/update`, + { values: template }, + "DEFINITION_NOT_FOUND", + 404 + ); + const first = { + name: "Zoom corporativo", + templateId: createdTemplate.savedId, + active: true, + notes: "", + characteristics: { [fieldId]: "Zoom" }, + }; + await error( + inventory, + { values: { ...first, templateId: assetTemplate.id } }, + "TEMPLATE_UNAVAILABLE" + ); + await error(`/resources/${otherId}`, { values: first }, "TEMPLATE_UNAVAILABLE"); + await error(inventory, { values: { ...first, characteristics: {} } }, "REQUIRED_CHARACTERISTICS"); + await error( + inventory, + { values: { ...first, characteristics: { [assetField.id]: "Ajeno" } } }, + "INVALID_CHARACTERISTICS" + ); + await error(inventory, { values: { ...first, personId: companyId } }, "INVALID_REQUEST", 400); + assert.equal((await request(inventory, { values: first }, "https://other.example")).status, 403); + assert.equal( + (await request(inventory, { values: first }, undefined, "a".repeat(64))).status, + 403 + ); + const created = await resource(inventory, first); + assert.equal(created.id[14], "7"); + assert.equal(created.history[0]?.id[14], "7"); + const repeated = await resource(`${inventory}/${created.id}/update`, first); + assert.equal(repeated.history.length, 1); + assert.equal(repeated.id, created.id); + await error( + `/resources/${otherId}/${created.id}/update`, + { values: first }, + "RESOURCE_NOT_FOUND", + 404 + ); + const altered = await resource(`${inventory}/${created.id}/update`, { + ...first, + active: false, + notes: "Fin de uso", + }); + assert.equal(altered.id, created.id); + assert.equal(altered.history.length, 2); + assert.equal(altered.history[0]?.values.active, true); + assert.equal(altered.values.active, false); + await error(inventory, { values: { ...first, name: " zoom CORPORATIVO " } }, "NAME_EXISTS"); + await error( + `${inventory}/${created.id}/update`, + { values: { ...first, templateId: stockTemplate.id } }, + "TEMPLATE_FIXED" + ); + // Cambiar una plantilla no altera las características ni la ficha ya registrada. + await save(`${company}/${createdTemplate.savedId}/update`, { + ...template, + name: "Plantilla revisada", + fields: [{ fieldId: stock.id, required: false }], + }); + const listResponse = await request(inventory); + const stored = InventoryResponseSchema.parse(await listResponse.json()).resources.find( + (item) => item.id === created.id + ); + assert.ok(stored); + assert.equal(stored.specification.templateName, "Recurso propio"); + assert.equal(stored.specification.fields[0]?.fieldId, fieldId); + await resource(`${inventory}/${created.id}/update`, { + ...first, + active: false, + notes: "Notas posteriores", + }); + // Una revisión fuera de secuencia se rechaza sin exponer detalles de persistencia. + const audit = await database.query<{ id: string; position: number }>( + "SELECT id, position FROM resource_revisions WHERE resourceId=:id ORDER BY position DESC LIMIT 1", + { replacements: { id: created.id }, type: QueryTypes.SELECT } + ); + const latest = audit[0]; + assert.ok(latest); + try { + await database.query("UPDATE resource_revisions SET position=999 WHERE id=:id", { + replacements: { id: latest.id }, + }); + const inconsistent = await request(inventory); + assert.equal(inconsistent.status, 500); + const failure = z + .strictObject({ code: z.string(), requestId: z.string() }) + .parse(await inconsistent.json()); + assert.equal(failure.code, "INTERNAL_ERROR"); + } finally { + await database.query("UPDATE resource_revisions SET position=:position WHERE id=:id", { + replacements: { id: latest.id, position: latest.position }, + }); + } + assert.equal((await request(inventory)).status, 200); + // Una plantilla desactivada impide altas, pero permite conservar y editar registros existentes. + assert.equal( + (await request(`${company}/${createdTemplate.savedId}/status`, { active: false })).status, + 200 + ); + await error(inventory, { values: { ...first, name: "Otro" } }, "TEMPLATE_UNAVAILABLE"); + await resource(`${inventory}/${created.id}/update`, { + ...first, + active: false, + notes: "Plantilla desactivada", + }); + assert.equal( + (await request(`${company}/${createdTemplate.savedId}/status`, { active: true })).status, + 200 + ); + const concurrentInput = { + name: "Concurrente", + templateId: stockTemplate.id, + active: true, + notes: "", + characteristics: {}, + }; + const concurrent = await Promise.all([ + request(inventory, { values: concurrentInput }), + request(inventory, { values: concurrentInput }), + ]); + assert.deepEqual(concurrent.map((response) => response.status).sort(), [201, 409]); + // Los buzones anteriores y los recursos básicos continúan en sus superficies originales. + assert.equal((await request(`/mailboxes/${companyId}`)).status, 200); + assert.equal((await request(`/access/${companyId}`)).status, 200); + assert.equal((await request(`/resources/${otherId}`)).status, 200); + assert.equal((await request(`${inventory}?unexpected=1`)).status, 400); + assert.equal((await request("/resources/not-a-uuid")).status, 400); + assert.equal((await request("/resources/82be74bb-894f-4cca-ac7b-dedc96c15689")).status, 404); +} diff --git a/apps/web/package.json b/apps/web/package.json index 7d85db5..68ae166 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,7 +21,8 @@ "@repo/shadcn-ui": "workspace:*", "@metis/identity": "workspace:*", "@metis/companies": "workspace:*", - "@metis/assets": "workspace:*" + "@metis/assets": "workspace:*", + "@metis/resources": "workspace:*" }, "devDependencies": { "@repo/typescript-config": "workspace:*", @@ -36,4 +37,4 @@ "#lib/*": "./src/lib/*.ts", "#hooks/*": "./src/hooks/*.ts" } -} \ No newline at end of file +} diff --git a/apps/web/src/i18n.ts b/apps/web/src/i18n.ts index 773a295..fcf85be 100644 --- a/apps/web/src/i18n.ts +++ b/apps/web/src/i18n.ts @@ -7,6 +7,7 @@ import { } from "@metis/companies/client"; import { coreEnglish, coreSpanish } from "@metis/core/client"; import { identityEnglish, identitySpanish } from "@metis/identity/client"; +import { resourcesEnglish, resourcesSpanish } from "@metis/resources/client"; import { createInstance } from "i18next"; export function createAppI18n(language = "es") { @@ -23,6 +24,7 @@ export function createAppI18n(language = "es") { companies: companiesSpanish, access: accessSpanish, assets: assetsSpanish, + resources: resourcesSpanish, }, en: { core: coreEnglish, @@ -30,6 +32,7 @@ export function createAppI18n(language = "es") { companies: companiesEnglish, access: accessEnglish, assets: assetsEnglish, + resources: resourcesEnglish, }, }, interpolation: { escapeValue: false }, diff --git a/docs/arquitectura/modelo-ddd-e-identificadores.md b/docs/arquitectura/modelo-ddd-e-identificadores.md index 7512f70..773c964 100644 --- a/docs/arquitectura/modelo-ddd-e-identificadores.md +++ b/docs/arquitectura/modelo-ddd-e-identificadores.md @@ -47,6 +47,8 @@ AccessWorkspace es una raíz adicional para el catálogo global o la configuraci AssetCatalog es la raíz del catálogo de activos; AssetDefinition, TemplateField y CatalogAvailability son entidades con UUID propio. AssetDefinitionValues reconstruye nombres y referencias como Value Objects. Consultar [Campos y plantillas de activos](../campos-y-plantillas-de-activos.md). +ResourceCatalog es la raíz del catálogo de recursos; ResourceDefinition, TemplateField y CatalogAvailability tienen UUID propios e independientes de activos. ResourceDefinitionValues encapsula las definiciones. Resource es la raíz del recurso registrado y ResourceRevision conserva sus revisiones; ResourceSpecification y ResourceValues capturan la plantilla y los valores inmutables. Consultar [Recursos, campos y plantillas](../recursos-campos-y-plantillas.md). + Asset es la raíz de cada activo real y AssetRevision una entidad con UUID propio, instantánea de valores, actor y fecha. AssetValues y AssetSpecification son Value Objects inmutables. La especificación capturada conserva los campos y reglas de la ficha aunque cambie el catálogo. Consultar [Inventario de activos](../inventario-de-activos.md). AssetRelationship es la raíz de un vínculo entre dos activos de una compañía, con UUID propio y auditoría de creación y retirada. AssetRelationshipValues reconstruye extremos, tipo y nombres como Value Objects y conserva una instantánea. Consultar [Relaciones entre activos](../relaciones-entre-activos.md). diff --git a/docs/plan-de-desarrollo.md b/docs/plan-de-desarrollo.md index 7ea0bd9..ba7042d 100644 --- a/docs/plan-de-desarrollo.md +++ b/docs/plan-de-desarrollo.md @@ -47,11 +47,16 @@ Persona y dispositivo fijos, cuenta de inicio de sesión opcional, perfiles vige Buzones independientes corporativos o externos, cuenta informática opcional, periodos de uso por persona, configuraciones POP/IMAP por perfil y avisos de revisión. Los registros anteriores mantienen UUID e historial y se vinculan explícitamente. Consultar [Buzones y correo por perfil](correos-en-dispositivos.md). +## Recursos con campos y plantillas: base implementada + +Catálogo independiente de activos, 12 campos y cuatro plantillas de serie, adiciones globales y por compañía, disponibilidad local y recursos con especificación capturada e historial. Los buzones y recursos básicos anteriores permanecen sin migrar. Consultar [Recursos, campos y plantillas](recursos-campos-y-plantillas.md). + ## Siguientes entregas: orden actualizado -1. Configuraciones del perfil: carpetas compartidas, impresoras y recursos; después programas y certificados. -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. Revisar y migrar los buzones y recursos básicos al nuevo modelo; comprobar UUID, historial, accesos y configuraciones antes de retirar la gestión anterior. +2. Configuraciones del perfil: carpetas compartidas, impresoras y recursos; después programas y certificados. +3. Teletrabajo y accesos remotos: autorización, equipo de origen, conectividad VPN y destino. Asistencia técnica remota independiente. +4. 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 new file mode 100644 index 0000000..8e4704a --- /dev/null +++ b/docs/recursos-campos-y-plantillas.md @@ -0,0 +1,62 @@ +# Recursos, campos y plantillas + +## Uso + +El catálogo global de recursos se abre desde Catálogos globales → Catálogo de recursos. Cada compañía dispone de Recursos, para registrar elementos concretos, y Catálogo de recursos, para añadir definiciones propias y ajustar la disponibilidad. + +Campos y plantillas de recursos son independientes de los de activos. Aunque se llamen igual, tienen UUID, datos, disponibilidad y referencias diferentes. El servidor rechaza campos de activos en plantillas de recursos y plantillas de activos al registrar recursos, y viceversa. El módulo resources no depende del módulo assets. + +Las altas y ediciones se abren en modal desde la cabecera o una acción explícita del listado. La ayuda está antes del pie con Guardar y Cancelar. Los selectores de campo y plantilla ofrecen alta rápida mediante SelectWithAction. Se permite un modal adicional; ese formulario no abre un tercer nivel. Una definición guardada permanece aunque se cancele el formulario principal. + +## Definiciones y ámbitos + +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. + +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. + +Cada compañía puede activar o desactivar una definición global para su ámbito. Usar configuración global elimina el efecto del ajuste local. Las definiciones de serie no se pueden editar; pueden desactivarse o copiarse como plantillas personalizables. No hay borrado físico. + +## Datos de serie + +Se incluyen 12 campos y cuatro plantillas: + +| Plantilla | Campos | +| --- | --- | +| Buzón de correo | Dirección de correo, proveedor y contexto | +| Carpeta compartida | Ruta UNC, servidor como referencia documental y descripción | +| Aplicación | Proveedor, URL y descripción | +| Certificado digital | Titular, emisor, caducidad, huella digital y finalidad | + +Todas las características de serie son opcionales. Los datos se alimentan con claves de serie estables: reiniciar no duplica definiciones, cambia UUID ni reactiva elementos desactivados. No se crean recursos, personas o compañías de ejemplo. + +Esta primera base valida tipos de datos, fechas y valores de listas. Dirección de correo, URL y ruta UNC son características de texto; todavía no tienen validadores específicos de su significado ni vínculos estructurados con servidores. No se almacenan contraseñas ni claves privadas como funcionalidades de este catálogo. + +## Recursos registrados + +Un recurso pertenece a una compañía y tiene nombre, plantilla, estado activo, notas y características. No se pide persona, puesto ni dispositivo. Registrar un recurso no concede permisos ni configura equipos. + +La plantilla queda fija tras el alta. Su especificación se captura al crear el recurso: nombres de campos, tipos, unidades, listas y obligatoriedad. Cambiar después el catálogo no reescribe las fichas ya registradas. Una plantilla desactivada impide nuevas altas, pero permite consultar y editar los recursos existentes con su especificación original. + +El nombre es único dentro de compañía y plantilla, incluyendo recursos inactivos. Las modificaciones conservan UUID y añaden revisión con fecha y usuario de Metis Desk. Repetir una edición equivalente no añade historial. + +## Arquitectura y persistencia + +ResourceCatalog es AggregateRoot. ResourceDefinition, TemplateField y CatalogAvailability son DomainEntity con UUID propio. ResourceDefinitionValues encapsula definiciones inmutables y referencias como UniqueID. + +Resource es AggregateRoot y ResourceRevision es DomainEntity. ResourceSpecification y ResourceValues son Value Objects con comparación semántica. Nuevas raíces, definiciones, asociaciones, ajustes y revisiones reciben UUID v7. + +Las tablas resource_catalogs, resource_definitions, resource_template_fields, resource_availability, resources y resource_revisions son independientes de las tablas de activos. Las migraciones son explícitas e idempotentes, con claves foráneas. Las escrituras se realizan en transacción bloqueando el catálogo global y después la compañía. El mapper reconstruye valores y verifica referencias, ámbito, especificación y última revisión. + +GET /api/resource-catalog/:scope consulta definiciones; POST en esa ruta crea; POST /api/resource-catalog/:scope/:id/update edita; POST /api/resource-catalog/:scope/:id/status ajusta disponibilidad. scope es global o UUID de compañía. + +GET /api/resources/:companyId lista recursos; POST en esa ruta crea; POST /api/resources/:companyId/:id/update edita. Todas las rutas requieren sesión. Las escrituras exigen origen y token CSRF; el actor procede de la sesión. + +## Convivencia y siguiente paso + +Los buzones existentes, sus usos y configuraciones por perfil y los recursos básicos de Cuentas y accesos permanecen disponibles. Esta entrega no los copia, migra ni elimina. Sus UUID e historial se conservan. + +Antes de migrarlos, se revisarán correspondencias, identidad, necesidades, permisos, cobertura y configuraciones por perfil. POP/IMAP, letras de unidad y versiones instaladas son datos de configuración, no características del recurso común. + +Después de completar y comprobar esa migración se retirará la gestión anterior, evitando mantener dos fuentes para un mismo recurso. Los validadores específicos y las relaciones del nuevo recurso se concretarán junto con ese paso. diff --git a/modules/companies/package.json b/modules/companies/package.json index 94d3971..c09c1bb 100644 --- a/modules/companies/package.json +++ b/modules/companies/package.json @@ -21,7 +21,8 @@ "react-hook-form": "7.72.1", "@hookform/resolvers": "5.2.2", "@tanstack/react-table": "8.21.3", - "@metis/assets": "workspace:*" + "@metis/assets": "workspace:*", + "@metis/resources": "workspace:*" }, "devDependencies": { "@repo/typescript-config": "workspace:*", diff --git a/modules/companies/src/web/components/companies-workspace.tsx b/modules/companies/src/web/components/companies-workspace.tsx index c56cb71..56d6382 100644 --- a/modules/companies/src/web/components/companies-workspace.tsx +++ b/modules/companies/src/web/components/companies-workspace.tsx @@ -1,4 +1,5 @@ import { AssetCatalogPage } from "@metis/assets/client"; +import { ResourceCatalogPage } from "@metis/resources/client"; import { Button } from "@repo/shadcn-ui/components/button"; import { Building2Icon, LayersIcon } from "@repo/shadcn-ui/lib/icons"; import { useQuery } from "@tanstack/react-query"; @@ -27,11 +28,12 @@ export function CompaniesWorkspace({ }) { const { t } = useTranslation("companies"); const { t: coreTranslation } = useTranslation("core"); + const { t: resourceTranslation } = useTranslation("resources"); const { t: assetTranslation } = useTranslation("assets"); const { t: accessTranslation } = useTranslation("access"); const query = useQuery({ queryKey: companyKeys.list, queryFn: companyApi.list }); const [location, setLocation] = useState(); - const [catalog, setCatalog] = useState<"access" | "assets">("access"); + const [catalog, setCatalog] = useState<"access" | "assets" | "resources">("access"); const companies = query.data?.companies ?? []; const current = location ?? (companies[0] ? { area: "company", id: companies[0].id } : { area: "directory" }); @@ -135,6 +137,13 @@ export function CompaniesWorkspace({ > {assetTranslation("title")} + ) : (