From f957dd40cf0ff6a976c235f51e582b18af231dce Mon Sep 17 00:00:00 2001 From: David Date: Mon, 5 Oct 2026 17:01:32 +0200 Subject: [PATCH] Impresoras en perfil de usuario --- apps/server/src/identity.test.ts | 48 ++- .../infrastructure/http/identity-runtime.ts | 10 +- apps/server/src/printers.integration.ts | 173 +++++++++++ .../src/resource-migration.integration.ts | 188 +++++++++++ apps/server/src/resources.integration.ts | 9 +- .../modelo-ddd-e-identificadores.md | 2 + docs/correos-en-dispositivos.md | 11 +- docs/cuentas-grupos-y-recursos.md | 11 +- docs/impresoras-del-perfil.md | 41 +++ docs/perfiles-de-trabajo.md | 2 +- docs/plan-de-desarrollo.md | 13 +- docs/recursos-campos-y-plantillas.md | 33 +- modules/companies/package.json | 2 +- .../printer-configuration-input.mapper.ts | 53 ++++ .../printer-configuration-policy.ts | 21 ++ .../printer-configuration-response.builder.ts | 26 ++ .../printer-configuration.repository.ts | 25 ++ .../printer-configuration.use-cases.ts | 53 ++++ .../printer-configuration-revision.entity.ts | 25 ++ .../domain/printer-configuration-values.ts | 24 ++ .../domain/printer-configuration.aggregate.ts | 92 ++++++ .../api/domain/printer-configuration.test.ts | 213 +++++++++++++ .../printer-configuration-values.ts | 72 +++++ .../api/infrastructure/http/access.routes.ts | 7 +- .../api/infrastructure/http/mailbox.routes.ts | 7 +- .../http/printer-configuration.routes.ts | 81 +++++ .../printer-configuration-migration.ts | 45 +++ ...rinter-configuration-persistence.mapper.ts | 122 ++++++++ ...uelize-printer-configuration.repository.ts | 133 ++++++++ modules/companies/src/api/module.ts | 31 +- modules/companies/src/common/index.ts | 4 +- .../src/common/printer-configurations.ts | 38 +++ .../companies/src/web/access/access-form.tsx | 26 +- .../src/web/access/access-workspace.tsx | 1 - .../companies/src/web/access/access.test.tsx | 9 +- .../src/web/components/company-detail.tsx | 23 +- .../components/configuration-quick-editor.tsx | 4 +- modules/companies/src/web/i18n.ts | 9 +- modules/companies/src/web/mail/mail.test.tsx | 61 +++- .../companies/src/web/mail/mailbox-form.tsx | 185 +---------- .../src/web/mail/mailbox-relations.tsx | 78 +++++ .../companies/src/web/mail/mailboxes-page.tsx | 207 ------------- .../companies/src/web/printers/printer-api.ts | 43 +++ .../printers/printer-configuration-form.tsx | 293 ++++++++++++++++++ .../printers/printer-configurations-page.tsx | 192 ++++++++++++ .../src/web/printers/printer-feedback.tsx | 11 + .../src/web/printers/printer-i18n.ts | 86 +++++ .../src/web/printers/printer.test.tsx | 83 +++++ .../src/web/profiles/work-profiles-page.tsx | 75 +++-- .../resource-catalog-response.builder.ts | 1 + .../api/application/resource-input.mapper.ts | 158 +++++++--- .../api/application/resource.repository.ts | 2 + .../src/api/application/resource.use-cases.ts | 32 ++ .../src/api/domain/resource-catalog-seed.ts | 2 + .../src/api/domain/resource-catalog.test.ts | 2 +- .../src/api/domain/resource-values.ts | 18 ++ .../src/api/domain/resource.aggregate.ts | 18 ++ .../resources/src/api/domain/resource.test.ts | 73 +++++ .../domain/value-objects/resource-values.ts | 86 +++++ .../infrastructure/http/resource.routes.ts | 1 + .../persistence/migrate-legacy-resources.ts | 244 +++++++++++++++ .../persistence/resource-compatibility.ts | 134 ++++++++ .../sequelize-resource.repository.ts | 27 ++ modules/resources/src/api/module.ts | 2 + modules/resources/src/common/index.ts | 1 + modules/resources/src/common/inventory.ts | 9 + modules/resources/src/web/index.ts | 3 + modules/resources/src/web/inventory-i18n.ts | 20 ++ modules/resources/src/web/resource-detail.tsx | 55 +++- modules/resources/src/web/resource-editor.tsx | 70 +++++ modules/resources/src/web/resource-form.tsx | 160 ++++++++-- .../src/web/resource-integration-fields.tsx | 165 ++++++++++ modules/resources/src/web/resources-page.tsx | 16 +- 73 files changed, 3761 insertions(+), 539 deletions(-) create mode 100644 apps/server/src/printers.integration.ts create mode 100644 apps/server/src/resource-migration.integration.ts create mode 100644 docs/impresoras-del-perfil.md create mode 100644 modules/companies/src/api/application/printer-configuration-input.mapper.ts create mode 100644 modules/companies/src/api/application/printer-configuration-policy.ts create mode 100644 modules/companies/src/api/application/printer-configuration-response.builder.ts create mode 100644 modules/companies/src/api/application/printer-configuration.repository.ts create mode 100644 modules/companies/src/api/application/printer-configuration.use-cases.ts create mode 100644 modules/companies/src/api/domain/printer-configuration-revision.entity.ts create mode 100644 modules/companies/src/api/domain/printer-configuration-values.ts create mode 100644 modules/companies/src/api/domain/printer-configuration.aggregate.ts create mode 100644 modules/companies/src/api/domain/printer-configuration.test.ts create mode 100644 modules/companies/src/api/domain/value-objects/printer-configuration-values.ts create mode 100644 modules/companies/src/api/infrastructure/http/printer-configuration.routes.ts create mode 100644 modules/companies/src/api/infrastructure/persistence/printer-configuration-migration.ts create mode 100644 modules/companies/src/api/infrastructure/persistence/printer-configuration-persistence.mapper.ts create mode 100644 modules/companies/src/api/infrastructure/persistence/sequelize-printer-configuration.repository.ts create mode 100644 modules/companies/src/common/printer-configurations.ts create mode 100644 modules/companies/src/web/mail/mailbox-relations.tsx delete mode 100644 modules/companies/src/web/mail/mailboxes-page.tsx create mode 100644 modules/companies/src/web/printers/printer-api.ts create mode 100644 modules/companies/src/web/printers/printer-configuration-form.tsx create mode 100644 modules/companies/src/web/printers/printer-configurations-page.tsx create mode 100644 modules/companies/src/web/printers/printer-feedback.tsx create mode 100644 modules/companies/src/web/printers/printer-i18n.ts create mode 100644 modules/companies/src/web/printers/printer.test.tsx create mode 100644 modules/resources/src/api/infrastructure/persistence/migrate-legacy-resources.ts create mode 100644 modules/resources/src/api/infrastructure/persistence/resource-compatibility.ts create mode 100644 modules/resources/src/web/resource-editor.tsx create mode 100644 modules/resources/src/web/resource-integration-fields.tsx diff --git a/apps/server/src/identity.test.ts b/apps/server/src/identity.test.ts index a7571e6..d9f4e79 100644 --- a/apps/server/src/identity.test.ts +++ b/apps/server/src/identity.test.ts @@ -17,6 +17,7 @@ import { } from "@metis/identity/common"; import { createResourceModule } from "@metis/resources/module"; import { QueryTypes } from "sequelize"; +import { z } from "zod/v4"; import { verifyAccess } from "./access.integration"; import { createApp } from "./app"; @@ -25,7 +26,9 @@ import { createDatabase, readDatabaseEnvironment } from "./config/database"; import { createIdentityRuntime } from "./infrastructure/http/identity-runtime"; import { verifyInventory } from "./inventory.integration"; import { verifyMail } from "./mail.integration"; +import { verifyPrinters } from "./printers.integration"; import { verifyRelationships } from "./relationships.integration"; +import { verifyResourceMigration } from "./resource-migration.integration"; import { verifyResources } from "./resources.integration"; import { verifyWorkProfiles } from "./work-profiles.integration"; import { verifyWorkstations } from "./workstations.integration"; @@ -35,7 +38,9 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de // Este proceso solo puede limpiar la base de pruebas dedicada. assert.equal(environment.DB_NAME, "metis_desk_test"); const database = createDatabase(environment); - const identity = await createIdentityRuntime(database, environment); + const identity = await createIdentityRuntime(database, environment, { + legacyResourceFixtures: true, + }); const server = createApp(() => undefined, identity).listen(0, "127.0.0.1"); try { await once(server, "listening"); @@ -49,6 +54,8 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de await database.query("DELETE FROM mailboxes"); await database.query("DELETE FROM folder_mapping_revisions"); await database.query("DELETE FROM folder_mappings"); + await database.query("DELETE FROM printer_configuration_revisions"); + await database.query("DELETE FROM printer_configurations"); await database.query("DELETE FROM work_profile_revisions"); await database.query("DELETE FROM work_profiles"); await database.query("DELETE FROM workstation_assets"); @@ -93,8 +100,8 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de await database.query( "ALTER TABLE company_contacts DROP COLUMN isContact, MODIFY position VARCHAR(100) NOT NULL, MODIFY email VARCHAR(254) NOT NULL, MODIFY phone VARCHAR(50) NOT NULL" ); - await createCompanyModule(database, (_request, _response, next) => next()); - await createCompanyModule(database, (_request, _response, next) => next()); + await createCompanyModule(database, (_request, _response, next) => next(), true); + await createCompanyModule(database, (_request, _response, next) => next(), true); const legacy = await database.query<{ id: string; email: string; isContact: number }>( "SELECT id,email,isContact FROM company_contacts WHERE id=:id", { replacements: { id: legacyPersonId }, type: QueryTypes.SELECT } @@ -750,6 +757,41 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de secondCompany.id, database ); + await verifyPrinters( + (...args) => operator.request(...args), + customer.id, + secondCompany.id, + database + ); + // El entorno normal bloquea las fichas antiguas; el modo de fixtures anterior no se usa aquí. + const migratedRuntime = await createIdentityRuntime(database, environment); + const migratedServer = createApp(() => undefined, migratedRuntime).listen(0, "127.0.0.1"); + try { + await once(migratedServer, "listening"); + const address = z.object({ port: z.number() }).parse(migratedServer.address()); + const session = await operator.session(); + await verifyResourceMigration( + (path, body) => + fetch(`http://127.0.0.1:${address.port}/api${path}`, { + method: body === undefined ? "GET" : "POST", + headers: { + cookie: operator.cookie(), + origin: environment.APP_ORIGIN, + "content-type": "application/json", + "x-csrf-token": session.csrfToken, + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }), + customer.id, + secondCompany.id, + database + ); + } finally { + migratedRuntime.stop(); + await new Promise((resolve, reject) => + migratedServer.close((error) => (error ? reject(error) : resolve())) + ); + } const countAfterAccess = await database.query<{ total: number }>( "SELECT COUNT(*) AS total FROM users", { type: QueryTypes.SELECT } diff --git a/apps/server/src/infrastructure/http/identity-runtime.ts b/apps/server/src/infrastructure/http/identity-runtime.ts index 340e3ec..8006c1e 100644 --- a/apps/server/src/infrastructure/http/identity-runtime.ts +++ b/apps/server/src/infrastructure/http/identity-runtime.ts @@ -10,16 +10,22 @@ import type { readDatabaseEnvironment } from "../../config/database"; export async function createIdentityRuntime( database: Sequelize, - environment: ReturnType + environment: ReturnType, + options: { legacyResourceFixtures?: boolean } = {} ) { await database.authenticate(); await migrateIdentity(database); const identity = await createIdentityModule(database, environment.APP_ORIGIN); - const companies = await createCompanyModule(database, identity.authenticate); + const companies = await createCompanyModule( + database, + identity.authenticate, + options.legacyResourceFixtures === true && environment.NODE_ENV === "test" + ); 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); + await resources.migrateLegacy(); const SequelizeStore = connectSequelize(session.Store); const store = new SequelizeStore({ db: database, diff --git a/apps/server/src/printers.integration.ts b/apps/server/src/printers.integration.ts new file mode 100644 index 0000000..4819e18 --- /dev/null +++ b/apps/server/src/printers.integration.ts @@ -0,0 +1,173 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; + +import { CatalogResponseSchema, InventoryResponseSchema } from "@metis/assets/common"; +import type { PrinterConfigurationInput } from "@metis/companies/common"; +import { + CompanyResponseSchema, + PrinterConfigurationResponseSchema, + PrinterConfigurationsResponseSchema, + WorkProfileResponseSchema, +} from "@metis/companies/common"; +import type { Sequelize } from "sequelize"; +import { z } from "zod/v4"; + +type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise; +export async function verifyPrinters( + request: Request, + companyId: string, + otherId: string, + database: Sequelize +) { + const personResponse = await request(`/companies/${companyId}/people`, { + name: "Persona prueba impresoras", + position: null, + email: null, + phone: null, + active: true, + isContact: false, + }); + assert.equal(personResponse.status, 201); + const person = CompanyResponseSchema.parse(await personResponse.json()).company.people.at(-1); + assert.ok(person); + const inventory = InventoryResponseSchema.parse( + await (await request(`/assets/${companyId}`)).json() + ); + const host = inventory.assets.find((item) => item.values.status !== "retired"); + assert.ok(host); + const catalog = CatalogResponseSchema.parse( + await (await request(`/asset-catalog/${companyId}`)).json() + ); + const template = catalog.items.find( + (item) => + item.values.kind === "template" && item.values.name === "Impresora" && item.effectiveActive + ); + assert.ok(template); + const addPrinter = async (name: string) => { + const response = await request(`/assets/${companyId}`, { + values: { + name, + templateId: template.id, + status: "available", + siteId: null, + personId: null, + notes: "", + characteristics: {}, + }, + }); + assert.equal(response.status, 201); + const current = InventoryResponseSchema.parse( + await (await request(`/assets/${companyId}`)).json() + ).assets.find((item) => item.values.name === name); + assert.ok(current); + return current; + }; + const first = await addPrinter("Impresora prueba USB"); + const second = await addPrinter("Impresora prueba red"); + const profileValues = { + name: "Perfil impresoras", + personId: person.id, + assetId: host.id, + accountId: null, + active: true, + notes: null, + }; + const profileResponse = await request(`/work-profiles/${companyId}`, { values: profileValues }); + assert.equal(profileResponse.status, 201); + const profile = WorkProfileResponseSchema.parse(await profileResponse.json()).profile; + const path = `/printer-configurations/${companyId}`; + const input: PrinterConfigurationInput = { + profileId: profile.id, + printerId: first.id, + connection: "usb", + isDefault: true, + state: "configured", + notes: null, + }; + const save = async (values: PrinterConfigurationInput, id?: string) => { + const response = await request(path + (id ? `/${id}/update` : ""), { values }); + assert.equal(response.status, id ? 200 : 201); + return PrinterConfigurationResponseSchema.parse(await response.json()).configuration; + }; + const error = async (values: unknown, code: string, id?: string, status = 409) => { + const response = await request(path + (id ? `/${id}/update` : ""), { values }); + assert.equal(response.status, status); + assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code); + }; + await error({ ...input, actorId: person.id }, "INVALID_REQUEST", undefined, 400); + await error({ ...input, printerId: randomUUID() }, "PRINTER_REFERENCE_NOT_FOUND", undefined, 404); + await error({ ...input, printerId: host.id }, "PRINTER_REFERENCE_NOT_FOUND", undefined, 404); + assert.equal((await request(path, { values: input }, "https://other.example")).status, 403); + assert.equal((await request(path, { values: input }, undefined, "a".repeat(64))).status, 403); + const attempts = await Promise.all([ + request(path, { values: input }), + request(path, { values: input }), + ]); + assert.deepEqual(attempts.map((response) => response.status).sort(), [201, 409]); + const successful = attempts.find((response) => response.status === 201); + assert.ok(successful); + const created = PrinterConfigurationResponseSchema.parse(await successful.json()).configuration; + assert.equal(created.id[14], "7"); + assert.equal(created.history[0]?.id[14], "7"); + assert.ok(created.history[0]?.actorName); + assert.deepEqual(await save(input, created.id), created); + await error({ ...input, printerId: second.id }, "DEFAULT_PRINTER_EXISTS"); + const network = await save({ + ...input, + printerId: second.id, + connection: "network", + isDefault: false, + state: "pending", + }); + await error({ ...input, printerId: second.id }, "PRINTER_TARGET_IMMUTABLE", created.id); + await error( + { ...input, state: "removed", isDefault: false }, + "INVALID_PRINTER_TRANSITION", + created.id + ); + await database.query("UPDATE printer_configurations SET defaultKey=NULL WHERE id=:id", { + replacements: { id: created.id }, + }); + try { + assert.equal((await request(path)).status, 500); + } finally { + await database.query("UPDATE printer_configurations SET defaultKey=:key WHERE id=:id", { + replacements: { id: created.id, key: profile.id }, + }); + } + const archive = await request(`/work-profiles/${companyId}/${profile.id}/update`, { + values: { ...profileValues, active: false }, + }); + assert.equal(archive.status, 200); + const reviewed = PrinterConfigurationsResponseSchema.parse( + await (await request(path)).json() + ).configurations; + assert.equal(reviewed.find((item) => item.id === created.id)?.needsReview, true); + await error( + { ...network.values, state: "configured" }, + "PRINTER_REFERENCE_UNAVAILABLE", + network.id + ); + const noted = await save({ ...created.values, notes: "Retirar tras salida" }, created.id); + assert.equal(noted.history.length, 2); + assert.equal(noted.history[0]?.values.notes, null); + const removal = await save( + { ...noted.values, state: "removal_pending", isDefault: false }, + created.id + ); + assert.equal(removal.needsReview, true); + const removed = await save({ ...removal.values, state: "removed" }, created.id); + assert.equal(removed.needsReview, false); + await error({ ...removed.values, notes: "Otro" }, "INVALID_PRINTER_TRANSITION", created.id); + assert.equal( + (await request(`/printer-configurations/${otherId}/${created.id}/update`, { values: input })) + .status, + 404 + ); + assert.equal( + PrinterConfigurationsResponseSchema.parse( + await (await request(`/printer-configurations/${otherId}`)).json() + ).configurations.length, + 0 + ); +} diff --git a/apps/server/src/resource-migration.integration.ts b/apps/server/src/resource-migration.integration.ts new file mode 100644 index 0000000..032ef6f --- /dev/null +++ b/apps/server/src/resource-migration.integration.ts @@ -0,0 +1,188 @@ +import assert from "node:assert/strict"; + +import { AccessResponseSchema, MailboxesResponseSchema } from "@metis/companies/common"; +import type { ResourceDTO } from "@metis/resources/common"; +import { InventoryResponseSchema, ResourceResponseSchema } from "@metis/resources/common"; +import { createResourceModule } from "@metis/resources/module"; +import { QueryTypes, type Sequelize } from "sequelize"; +import { z } from "zod/v4"; + +type Request = (path: string, body?: unknown) => Promise; +export async function verifyResourceMigration( + request: Request, + companyId: string, + otherId: string, + database: Sequelize +) { + const sources = await database.query<{ id: string; kind: string }>( + "SELECT id,'mailbox' AS kind FROM mailboxes WHERE companyId=:companyId UNION ALL SELECT i.id,'access' AS kind FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId WHERE w.companyId=:companyId AND i.kind='resource'", + { replacements: { companyId }, type: QueryTypes.SELECT } + ); + const originalMail = MailboxesResponseSchema.parse( + await (await request(`/mailboxes/${companyId}`)).json() + ); + const originalAccess = AccessResponseSchema.parse( + await (await request(`/access/${companyId}`)).json() + ); + const result = await request(`/resources/${companyId}`); + assert.equal(result.status, 200); + const inventory = InventoryResponseSchema.parse(await result.json()); + for (const source of sources) { + const resource = inventory.resources.find((item) => item.id === source.id); + assert.ok(resource); + assert.equal(resource.values.integration?.kind, source.kind); + verifySourceValues(resource, originalMail, originalAccess); + const table = source.kind === "mailbox" ? "mailbox_revisions" : "access_revisions"; + const revisions = await database.query<{ id: string; actorId: string; recordedAt: string }>( + `SELECT id,actorId,recordedAt FROM ${table} WHERE itemId=:id`, + { replacements: { id: source.id }, type: QueryTypes.SELECT } + ); + assert.equal(resource.history.length, revisions.length); + for (const old of revisions) { + const current: ResourceDTO["history"][number] | undefined = resource.history.find( + (item) => item.id === old.id + ); + assert.ok(current); + assert.equal(current.actorId, old.actorId); + assert.equal(current.recordedAt, old.recordedAt); + } + } + const module = await createResourceModule(database, (_request, _response, next) => next()); + await module.migrateLegacy(); + const again = InventoryResponseSchema.parse( + await (await request(`/resources/${companyId}`)).json() + ); + assert.deepEqual( + again.resources.map((item) => [item.id, item.history.map((row) => row.id)]).sort(), + inventory.resources.map((item) => [item.id, item.history.map((row) => row.id)]).sort() + ); + const mailbox = inventory.resources.find((item) => item.values.integration?.kind === "mailbox"); + assert.ok(mailbox && mailbox.values.integration?.kind === "mailbox"); + const integration = mailbox.values.integration; + const values = { ...mailbox.values, active: false, notes: "Revisión desde Recursos" }; + const system = inventory.references?.find((item) => item.kind === "system"); + assert.ok(system); + const wrongReference = await request(`/resources/${companyId}/${mailbox.id}/update`, { + values: { ...values, integration: { ...integration, accountId: system.id } }, + }); + assert.equal(wrongReference.status, 409); + assert.equal( + z.object({ code: z.string() }).parse(await wrongReference.json()).code, + "REFERENCE_UNAVAILABLE" + ); + const invalidEmail = await request(`/resources/${companyId}/${mailbox.id}/update`, { + values: { + ...values, + characteristics: { + ...values.characteristics, + [integration.addressFieldId]: "correo-invalido", + }, + }, + }); + assert.equal(invalidEmail.status, 409); + assert.equal( + z.object({ code: z.string() }).parse(await invalidEmail.json()).code, + "INVALID_EMAIL" + ); + const duplicate = await request(`/resources/${companyId}`, { + values: { + ...mailbox.values, + name: "Otro nombre", + characteristics: { + ...mailbox.values.characteristics, + [integration.addressFieldId]: String( + mailbox.values.characteristics[integration.addressFieldId] + ).toUpperCase(), + }, + }, + }); + assert.equal(duplicate.status, 409); + assert.equal(z.object({ code: z.string() }).parse(await duplicate.json()).code, "NAME_EXISTS"); + const changed = await request(`/resources/${companyId}/${mailbox.id}/update`, { values }); + assert.equal(changed.status, 200); + const updated = ResourceResponseSchema.parse(await changed.json()).resource; + assert.equal(updated.history.length, mailbox.history.length + 1); + assert.equal(updated.id, mailbox.id); + const boxes = MailboxesResponseSchema.parse( + await (await request(`/mailboxes/${companyId}`)).json() + ); + assert.equal(boxes.mailboxes.find((item) => item.id === mailbox.id)?.values.active, false); + assert.equal( + boxes.mailboxes.find((item) => item.id === mailbox.id)?.values.accountId, + integration.accountId + ); + assert.equal( + (await request(`/resources/${otherId}/${mailbox.id}/update`, { values })).status, + 404 + ); + assert.equal( + ( + await request(`/mailboxes/${companyId}/${mailbox.id}/update`, { + values: { + address: mailbox.values.characteristics[integration.addressFieldId], + kind: integration.mailboxKind, + context: null, + accountId: integration.accountId, + active: true, + notes: null, + }, + }) + ).status, + 409 + ); + const access = inventory.resources.find((item) => item.values.integration?.kind === "access"); + assert.ok(access && access.values.integration?.kind === "access"); + const referenceField = access.values.integration.referenceFieldId; + const nextValues = { + ...access.values, + characteristics: { ...access.values.characteristics, [referenceField]: "\\\\SERVER\\Revision" }, + }; + const saved = await request(`/resources/${companyId}/${access.id}/update`, { + values: nextValues, + }); + assert.equal(saved.status, 200); + const snapshot = AccessResponseSchema.parse(await (await request(`/access/${companyId}`)).json()); + const projected = snapshot.items.find((item) => item.id === access.id); + assert.ok(projected && projected.values.kind === "resource"); + assert.equal(projected.values.reference, nextValues.characteristics[referenceField]); + const oldWrite = await request(`/access/${companyId}/${access.id}/update`, { + values: projected.values, + }); + assert.equal(oldWrite.status, 409); + assert.equal(z.object({ code: z.string() }).parse(await oldWrite.json()).code, "RESOURCE_MOVED"); +} + +function verifySourceValues( + resource: ResourceDTO, + originalMail: z.infer, + originalAccess: z.infer +) { + const source = { id: resource.id }; + const integration = resource.values.integration; + if (integration?.kind === "mailbox") { + const sourceValues = originalMail.mailboxes.find((item) => item.id === source.id)?.values; + assert.ok(sourceValues); + assert.equal(resource.values.characteristics[integration.addressFieldId], sourceValues.address); + assert.equal( + resource.values.characteristics[integration.contextFieldId] ?? null, + sourceValues.context + ); + assert.equal(integration.mailboxKind, sourceValues.kind); + assert.equal(integration.accountId, sourceValues.accountId); + assert.equal(resource.values.active, sourceValues.active); + assert.equal(resource.values.notes, sourceValues.notes ?? ""); + } else if (integration?.kind === "access") { + const sourceValues = originalAccess.items.find((item) => item.id === source.id)?.values; + assert.ok(sourceValues?.kind === "resource"); + assert.equal(resource.values.name, sourceValues.name); + assert.equal( + resource.values.characteristics[integration.referenceFieldId] ?? null, + sourceValues.reference + ); + assert.equal(integration.resourceTypeId, sourceValues.resourceTypeId); + assert.equal(integration.systemId, sourceValues.systemId); + assert.equal(integration.environmentId, sourceValues.environmentId); + assert.equal(resource.values.active, sourceValues.active); + assert.equal(resource.values.notes, sourceValues.notes ?? ""); + } +} diff --git a/apps/server/src/resources.integration.ts b/apps/server/src/resources.integration.ts index 96e305d..e507389 100644 --- a/apps/server/src/resources.integration.ts +++ b/apps/server/src/resources.integration.ts @@ -43,8 +43,8 @@ export async function verifyResources( 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.equal(initial.items.length, 18); + assert.equal(initial.items.filter((item) => item.values.kind === "template").length, 5); assert.ok(initial.items.every((item) => item.builtin && item.id[14] === "7")); assert.ok( initial.items.every((item) => @@ -240,7 +240,10 @@ export async function verifyResources( templateId: stockTemplate.id, active: true, notes: "", - characteristics: {}, + characteristics: { + [initial.items.find((item) => item.builtinKey === "field:email")?.id ?? ""]: + "concurrent@example.test", + }, }; const concurrent = await Promise.all([ request(inventory, { values: concurrentInput }), diff --git a/docs/arquitectura/modelo-ddd-e-identificadores.md b/docs/arquitectura/modelo-ddd-e-identificadores.md index 773c964..945a06d 100644 --- a/docs/arquitectura/modelo-ddd-e-identificadores.md +++ b/docs/arquitectura/modelo-ddd-e-identificadores.md @@ -61,6 +61,8 @@ Mailbox y MailboxUse son raíces de buzón y periodo de uso por persona. Mailbox MailConfiguration es el agregado de un buzón configurado en un perfil; MailConfigurationRevision conserva cada instantánea con UUID propio. MailConfigurationValues encapsula referencias UniqueID y dirección EmailAddress. Consultar [Correos en dispositivos](../correos-en-dispositivos.md). +PrinterConfiguration es la raíz de una impresora configurada en un perfil y PrinterConfigurationRevision conserva cada instantánea con UUID propio. PrinterConfigurationValues encapsula referencias UniqueID, conexión, predeterminada, estado y notas opcionales Maybe. Consultar [Impresoras del perfil](../impresoras-del-perfil.md). + Los módulos de documentos y tickets todavía no existen. Se declararán como agregados o entidades al implementar sus operaciones y reglas de consistencia. La decisión de pertenencia a un agregado se toma por sus invariantes y transacciones; no convierte automáticamente cada tabla en una raíz ni fuerza todo el cliente y sus tickets dentro de un solo agregado. Cada entidad que se incorpore deberá tener UniqueID desde su primera implementación, mapper de persistencia y pruebas de identidad, invariantes, rehidratación y actualización. diff --git a/docs/correos-en-dispositivos.md b/docs/correos-en-dispositivos.md index d06f4ef..68bb0ec 100644 --- a/docs/correos-en-dispositivos.md +++ b/docs/correos-en-dispositivos.md @@ -4,7 +4,7 @@ Un buzón identifica una dirección de correo, independiente de la cuenta inform ## Buzones -La sección «Buzones de correo» permite crear y editar dirección, tipo corporativo o externo, contexto opcional, estado activo, notas y una cuenta informática relacionada opcional. El dominio de la dirección no determina su propiedad: una compañía puede trabajar con varios dominios y las personas pueden utilizar correos externos personales, de clientes, proveedores o colaboraciones. +La sección «Recursos», mediante la plantilla «Buzón de correo», permite crear y editar dirección, tipo corporativo o externo, contexto opcional, estado activo, notas y una cuenta informática relacionada opcional. La ficha muestra su historial y los periodos de uso por persona. El dominio de la dirección no determina su propiedad: una compañía puede trabajar con varios dominios y las personas pueden utilizar correos externos personales, de clientes, proveedores o colaboraciones. La cuenta relacionada es solo una referencia documental. No concede acceso ni es obligatoria. Su desactivación o caducidad no sustituye la revisión del buzón y sus usos. Dirección y contexto se pueden actualizar con historial; el tipo queda fijado desde el alta. La dirección se normaliza y es única dentro de la compañía, incluso en buzones inactivos. @@ -43,12 +43,12 @@ Los registros abiertos anteriores aparecen como pendientes de vinculación. Edit ## Arquitectura y API -Mailbox y MailboxUse son AggregateRoot; sus revisiones son DomainEntity. MailboxValues y MailboxUseValues son Value Objects inmutables, con EmailAddress, UniqueID, CalendarDate y Maybe. MailConfiguration conserva su agregado y revisiones, con referencias al perfil y al buzón. Las altas generan UUID v7; actualizar y rehidratar conservan la identidad. +Resource es el agregado editable del buzón. Mailbox permanece como proyección de compatibilidad de lectura y MailboxUse mantiene su AggregateRoot; sus revisiones son DomainEntity. MailboxValues y MailboxUseValues son Value Objects inmutables, con EmailAddress, UniqueID, CalendarDate y Maybe. MailConfiguration conserva su agregado y revisiones, con referencias al perfil y al buzón. Las altas generan UUID v7; actualizar y rehidratar conservan la identidad. Las migraciones explícitas e idempotentes crean mailboxes, mailbox_uses y sus revisiones, y amplían mail_configurations. La persistencia verifica referencias, pertenencia, historial ordenado, última revisión y claves derivadas. Las escrituras son transaccionales, bloqueando primero la compañía. El inventario se consulta mediante el servicio público de activos, inyectado en compañías. - GET /api/mailboxes/:companyId devuelve buzones, usos e historial. -- POST /api/mailboxes/:companyId y POST /api/mailboxes/:companyId/:id/update reciben values del buzón. +- POST /api/resources/:companyId y POST /api/resources/:companyId/:id/update guardan el buzón como recurso. Las antiguas escrituras de buzón devuelven 409 RESOURCE_MOVED. - POST /api/mailboxes/:companyId/uses y POST /api/mailboxes/:companyId/uses/:id/update reciben values del periodo. - GET /api/mail-configurations/:companyId devuelve configuraciones, revisiones, legacy y needsReview calculado. - POST /api/mail-configurations/:companyId y POST /api/mail-configurations/:companyId/:id/update reciben values con profileId, mailboxId, method, state y notes. @@ -60,3 +60,8 @@ Sesión obligatoria; las escrituras exigen origen válido y CSRF. El actor se ob Incluye buzones corporativos y externos, varios buzones por persona, varios perfiles por buzón, periodos de uso, revisión de retirada y conservación del legado. Los dispositivos externos personales, programas, carpetas, impresoras, certificados, listas de baja, licencias y roles quedan pendientes. Las pruebas cubren fechas y solapes, permisos vigentes, múltiples buzones/perfiles/personas, UUID e historial, idempotencia, ámbito, concurrencia, protección HTTP, retirada, vinculación explícita del legado, corrupción persistida y textos es/en con ayuda inferior. + + +## Traspaso al catálogo de recursos + +Los buzones existentes y todas sus revisiones se han migrado manteniendo los UUID, actores, fechas y relaciones. Su edición ya utiliza Resource. Las tablas antiguas se conservan como proyecciones transaccionales para los usos y configuraciones actuales, sin gestión independiente. Véase [Recursos, campos y plantillas](recursos-campos-y-plantillas.md). diff --git a/docs/cuentas-grupos-y-recursos.md b/docs/cuentas-grupos-y-recursos.md index bdcc277..27f97b1 100644 --- a/docs/cuentas-grupos-y-recursos.md +++ b/docs/cuentas-grupos-y-recursos.md @@ -3,7 +3,7 @@ ## Dónde se gestionan - Catálogos informáticos, en la navegación principal: sistemas, tipos de recurso, permisos disponibles y plantillas de grupos comunes. -- Compañías → ficha → Cuentas y accesos: cuentas, relaciones con personas, grupos, miembros, recursos, permisos concedidos, necesidades, cobertura y entornos. También permite añadir sistemas, tipos y permisos propios de la compañía. +- Compañías → ficha → Cuentas y accesos: cuentas, relaciones con personas, grupos, miembros, permisos concedidos, necesidades, cobertura y entornos. También permite añadir sistemas, tipos y permisos propios de la compañía. - Los botones de alta están a la derecha de la cabecera. Los formularios sencillos se abren en modal; los extensos sustituyen el listado por una página de formulario. Edición e historial son acciones explícitas. Los catálogos comienzan vacíos. Definir primero los sistemas comunes que se utilicen, como Dominio, Correo, Microsoft 365 o ERP. Las altas posteriores seleccionan esas definiciones; no se crean datos de ejemplo en la base de trabajo. @@ -79,7 +79,7 @@ Cada cambio registra una revisión con UUID, fecha y usuario de Metis Desk. La i ## Recursos y permisos -Cada recurso tiene nombre, tipo, estado, ubicación o referencia opcional, sistema y entorno opcionales y observaciones. Puede existir antes de inventariar el activo que lo aloja. Su nombre es único por compañía, tipo, sistema y entorno. +Los recursos se gestionan ahora en Compañías → Recursos, mediante la plantilla Recurso de acceso. Conservan nombre, tipo, estado, referencia opcional, sistema y entorno opcionales y observaciones. Puede existir antes de inventariar el activo que lo aloja. Su nombre es único por compañía, tipo, sistema y entorno. La ubicación o referencia puede cambiar manteniendo el UUID. Tipo, sistema y entorno identifican el contexto del recurso y quedan fijos. @@ -143,3 +143,10 @@ Pasan typecheck, lint, test, check y build. La compilación conserva el aviso pr ## Pendiente para futuras entregas Configuración por ordenador o móvil, POP/IMAP, teletrabajo y acceso remoto, inventario y modelos de activos, procesos y plantillas de alta/cambio/salida, relación con licencias e integraciones con sistemas externos. + + +## Migración de recursos básicos + +Los antiguos AccessItem de tipo resource se han traspasado a Resource sin cambiar sus UUID ni los de sus revisiones. Permisos, necesidades, vías de cobertura y mapeos de carpetas siguen enlazados. El formulario anterior y la pestaña de recursos se han retirado; el alta rápida desde permisos o necesidades abre el formulario del nuevo modelo. + +access_items y su historial de tipo resource son proyecciones de compatibilidad que resources actualiza dentro de su transacción. Las escrituras antiguas de ese tipo devuelven 409 RESOURCE_MOVED; cuentas, grupos, permisos y necesidades mantienen sus rutas. Véase [Recursos, campos y plantillas](recursos-campos-y-plantillas.md). diff --git a/docs/impresoras-del-perfil.md b/docs/impresoras-del-perfil.md new file mode 100644 index 0000000..319982c --- /dev/null +++ b/docs/impresoras-del-perfil.md @@ -0,0 +1,41 @@ +# Impresoras del perfil + +## Uso + +Cada compañía dispone de Impresoras del perfil. También se accede desde la acción del mismo nombre en cada perfil de trabajo; esa consulta se filtra por perfil. Configurar impresora está a la derecha de la cabecera y abre un modal. No se muestran formularios junto al listado. + +Cada configuración vincula un perfil (persona y dispositivo) con una impresora del inventario de esa compañía. Se indica conexión USB o red, si es predeterminada, estado y notas opcionales. El usuario selecciona el activo que representa la impresora; no se infiere su función del nombre de una plantilla, porque las plantillas son configurables. El equipo anfitrión del perfil no puede seleccionarse como su propia impresora. + +La asignación del activo, la relación física entre activos y la configuración de la impresora en un perfil son independientes. Una impresora puede servir a varios perfiles y una persona puede usar varias impresoras. Registrar una configuración no instala controladores ni cambia físicamente el equipo. + +Los desplegables de perfil e impresora reutilizan SelectWithAction para alta rápida. El perfil creado o el activo creado se seleccionan al guardar. Cancelar el modal hijo conserva el formulario padre. Los elementos guardados permanecen aunque se cancele el padre. Solo hay un modal adicional y no se permite otro nivel de alta. La ayuda con finalidad, ámbito y ejemplos aparece antes del pie Guardar/Cancelar. Textos en español e inglés. + +## Reglas + +Perfil e impresora quedan fijos tras el alta. Conexión, predeterminada, estado y notas se pueden modificar con historial. Solo existe una configuración abierta por pareja perfil/impresora; los estados terminales conservan su historial y permiten crear otra configuración con nuevo UUID. + +Una sola impresora puede ser predeterminada en cada perfil, tanto pendiente como configurada. Para cambiar la predeterminada hay que desmarcar primero la anterior. Al pasar a retirada pendiente o a un estado terminal, el formulario desmarca predeterminada; el servidor también rechaza esos estados si se envía true. + +Estados: +- Pendiente de configurar → Configurada o No llegó a configurarse. +- Configurada → Pendiente de retirar. +- Pendiente de retirar → Retirada. +- Retirada y No llegó a configurarse son terminales. + +Una configuración nueva comienza pendiente o configurada. Las referencias siempre pertenecen a la compañía. Para dar de alta, pasar a configurada, cambiar la conexión o marcar predeterminada se exige perfil y persona activos, equipo anfitrión e impresora no retirados. Las notas, desmarcar predeterminada y el proceso de retirada permanecen disponibles aunque las referencias hayan perdido disponibilidad. + +Las configuraciones abiertas señalan revisión cuando se archiva el perfil, se desactiva la persona, se retira el equipo o la impresora, o existe una retirada pendiente. No se retiran automáticamente configuraciones ni se revocan permisos reales. La configuración de las cuentas y autorizaciones del perfil se revisa en su propia ficha. + +## Dominio y persistencia + +PrinterConfiguration es AggregateRoot. PrinterConfigurationRevision es DomainEntity. Cada raíz y revisión nueva tiene UniqueID UUID v7. PrinterConfigurationValues es un Value Object inmutable con referencias UniqueID y notas opcionales Maybe. Las modificaciones comparan valores semánticamente, conservan identidad y solo añaden revisión si cambian. Rehidratar conserva el historial original. + +printer_configurations guarda raíz, compañía, perfil, impresora y claves únicas de pareja abierta y predeterminada. printer_configuration_revisions guarda instantáneas ordenadas, actor autenticado y fecha UTC, con UUID y claves foráneas. Migraciones explícitas e idempotentes; sin borrado físico. + +La escritura bloquea la compañía antes de validar y guardar raíz e historial en la misma transacción. Este bloqueo y las restricciones únicas protegen las altas y predeterminadas concurrentes. El inventario se obtiene mediante el servicio público de assets; no se importan sus modelos. El mapper detecta claves derivadas inválidas, destinos alterados, revisiones de otro registro, fechas u orden incorrectos, transiciones inválidas y desacuerdo de la última revisión. + +GET /api/printer-configurations/:companyId lista configuraciones. POST en esa ruta crea; POST /api/printer-configurations/:companyId/:id/update edita con {values}. Sesión obligatoria y escrituras con origen y CSRF. El actor procede de la sesión. + +## Comprobaciones + +Pruebas de dominio, persistencia, contratos y textos es/en. La integración con MariaDB de test cubre UUID e historial, repetición sin revisión extra, altas concurrentes, predeterminada única, pertenencia a compañía, protección HTTP, corrupción persistida, aviso al archivar perfil y retirada posterior. diff --git a/docs/perfiles-de-trabajo.md b/docs/perfiles-de-trabajo.md index 6e15220..cb81840 100644 --- a/docs/perfiles-de-trabajo.md +++ b/docs/perfiles-de-trabajo.md @@ -42,6 +42,6 @@ Las pruebas cubren cuentas opcionales, parejas vigentes únicas, múltiples pers La base del perfil incorpora ahora [buzones y configuraciones de correo](correos-en-dispositivos.md). Cada perfil puede tener varios buzones y cada configuración conserva su método y estado. La acción «Correos en dispositivos» de la fila abre la consulta filtrada del perfil. No se confunde la cuenta de inicio de sesión con el buzón. -La configuración de carpetas, programas, impresoras y certificados, y la vista completa Entorno actual siguen pendientes. El mapa visual queda aplazado. +El perfil incorpora también carpetas compartidas e [impresoras por perfil](impresoras-del-perfil.md), con acciones para abrir cada consulta filtrada. Programas, certificados y la vista completa Entorno actual siguen pendientes. El mapa visual queda aplazado. La comprobación en navegador verifica la nueva sección, los desplegables de persona y dispositivo, la apertura de las tres altas rápidas y la conservación del formulario al cancelarlas. En puestos se comprueba la carga de una composición anterior, el selector compartido, su incorporación visual y la cancelación sin persistir cambios. diff --git a/docs/plan-de-desarrollo.md b/docs/plan-de-desarrollo.md index ba7042d..f18d41c 100644 --- a/docs/plan-de-desarrollo.md +++ b/docs/plan-de-desarrollo.md @@ -49,14 +49,17 @@ Buzones independientes corporativos o externos, cuenta informática opcional, pe ## 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). +Catálogo independiente de activos, 13 campos y cinco 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 están migrados, conservando UUID, historial y relaciones. La gestión anterior se ha retirado; sus tablas se mantienen como proyecciones para permisos y configuraciones existentes. Consultar [Recursos, campos y plantillas](recursos-campos-y-plantillas.md). + +## Impresoras por perfil: implementado + +Configuraciones USB o red sobre activos de la compañía, una predeterminada por perfil, estados de preparación y retirada, historial y avisos de revisión. Disponible desde la compañía y cada perfil. Modales, ayuda inferior y altas rápidas. Consultar [Impresoras del perfil](impresoras-del-perfil.md). ## Siguientes entregas: orden actualizado -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. +1. Configuraciones del perfil: aplicaciones y certificados como recursos; después ficha del entorno actual reuniendo correos, carpetas, impresoras y los nuevos recursos. +2. Teletrabajo y accesos remotos: autorización, equipo de origen, conectividad VPN y destino. Asistencia técnica remota independiente. +3. Procesos de alta, cambio y salida con seguimiento de configuraciones y retiradas. Plantillas reutilizables entre compañías; ejecuciones y datos propios. Este orden se concreta antes de cada entrega. Las licencias y su relación con cuentas se explorarán posteriormente. diff --git a/docs/recursos-campos-y-plantillas.md b/docs/recursos-campos-y-plantillas.md index 8e4704a..7b09aed 100644 --- a/docs/recursos-campos-y-plantillas.md +++ b/docs/recursos-campos-y-plantillas.md @@ -20,7 +20,7 @@ Cada compañía puede activar o desactivar una definición global para su ámbit ## Datos de serie -Se incluyen 12 campos y cuatro plantillas: +Se incluyen 13 campos y cinco plantillas: | Plantilla | Campos | | --- | --- | @@ -28,10 +28,11 @@ Se incluyen 12 campos y cuatro plantillas: | 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 | +| Recurso de acceso | Referencia; contexto estructurado de tipo de acceso, sistema y entorno | -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. +Las características de serie son opcionales salvo la dirección válida exigida al registrar un buzón. 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. +Se validan tipos, fechas, listas y dirección de correo de los buzones. La dirección se normaliza a minúsculas y es única en la compañía, incluso para buzones inactivos. URL y ruta UNC siguen siendo texto sin vínculos estructurados con servidores. No se almacenan contraseñas ni claves privadas como funcionalidades de este catálogo. ## Recursos registrados @@ -39,7 +40,7 @@ Un recurso pertenece a una compañía y tiene nombre, plantilla, estado activo, 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. +El nombre es único dentro de compañía y plantilla, incluyendo recursos inactivos. Los recursos de acceso distinguen además tipo, sistema y entorno para conservar los contextos existentes. 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 @@ -53,10 +54,26 @@ GET /api/resource-catalog/:scope consulta definiciones; POST en esa ruta crea; P 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 +## Migración de buzones y recursos de acceso: implementada -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. +Al iniciar el servidor, después de las migraciones de correo y perfiles, se traspasan todos los buzones y los recursos básicos de Cuentas y accesos. El proceso usa una transacción y bloquea catálogo y compañías en orden estable. Reiniciar no duplica registros ni añade revisiones. Una colisión de identidad, un historial inválido o una última revisión incoherente detiene el traspaso y revierte la transacción. -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. +Cada recurso conserva su UUID original; cada revisión conserva UUID, actor, fecha, orden y valores equivalentes. No se crea una revisión artificial de migración. Los usos del buzón, configuraciones de correo, permisos, necesidades, cobertura y carpetas de perfiles conservan sus referencias. -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. +Correspondencias: +- Buzón → plantilla Buzón de correo; dirección y contexto pasan a características. Tipo corporativo/externo y cuenta relacionada opcional se conservan como contexto estructurado. El nombre inicial es la dirección (hasta 100 caracteres; la dirección completa permanece en su campo). +- Recurso básico → plantilla Recurso de acceso; referencia pasa a característica. Nombre, notas, estado, tipo de acceso, sistema y entorno se conservan. No se deduce una plantilla especializada por su nombre o tipo. + +La ficha editable y su historial pertenecen a Resource. La integración es parte de ResourceValues, con campos estrictos y comparación semántica. El tipo de buzón, los identificadores de sus campos y el contexto de acceso quedan fijos; la cuenta documental del buzón puede cambiar con historial. + +En la interfaz desaparecen el listado Buzones de correo y la pestaña Recursos de Cuentas y accesos. Todos se crean y editan en Recursos mediante modal. La ficha de un buzón permite vincular personas o finalizar su uso. Las altas rápidas de buzón o recurso desde otros formularios guardan también en el nuevo modelo. + +Para conservar las claves foráneas y los lectores actuales de permisos y perfiles, mailboxes/mailbox_revisions y access_items/access_revisions de tipo resource se mantienen como proyecciones de compatibilidad. El adaptador de infraestructura de resources actualiza estas proyecciones en la misma transacción que la ficha; solo añade nuevas revisiones y nunca reescribe las originales. No son un segundo catálogo editable. Las antiguas rutas de escritura de buzón y recurso básico devuelven 409 RESOURCE_MOVED; consultas, usos, permisos y configuraciones continúan disponibles. + +POP/IMAP pertenece a la configuración de correo por perfil; letras de unidad pertenecen al mapeo de carpetas. No se incorporan a los campos del recurso común. La migración no configura equipos ni concede o retira permisos reales. + +## Validación + +Las pruebas con MariaDB de test comprueban UUID y revisiones originales, repetición idempotente, edición canónica y actualización de proyecciones, aislamiento por compañía y rechazo de escrituras antiguas. Se mantienen las pruebas de múltiples correos, permisos, necesidades, cobertura y perfiles. Las pruebas de dominio cubren correo inválido o ausente, normalización, cuenta opcional, cambio auditado y tipo fijo. + +La comprobación en navegador usa los datos existentes de desarrollo, sin crear registros ficticios: ficha, historial, personas vinculadas, edición modal y selectores. diff --git a/modules/companies/package.json b/modules/companies/package.json index c09c1bb..225cc1c 100644 --- a/modules/companies/package.json +++ b/modules/companies/package.json @@ -10,7 +10,7 @@ }, "scripts": { "typecheck": "tsc -p tsconfig.json", - "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/components/companies-page.test.tsx src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/api/domain/mailbox.test.ts src/web/access/access.test.tsx src/web/mail/mail.test.tsx src/api/domain/work-profile.test.ts src/web/profiles/profile.test.tsx" + "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/components/companies-page.test.tsx src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/api/domain/mailbox.test.ts src/web/access/access.test.tsx src/web/mail/mail.test.tsx src/api/domain/work-profile.test.ts src/web/profiles/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printers/printer.test.tsx" }, "dependencies": { "@repo/rdx-ddd": "workspace:*", diff --git a/modules/companies/src/api/application/printer-configuration-input.mapper.ts b/modules/companies/src/api/application/printer-configuration-input.mapper.ts new file mode 100644 index 0000000..4acef7b --- /dev/null +++ b/modules/companies/src/api/application/printer-configuration-input.mapper.ts @@ -0,0 +1,53 @@ +import { Result } from "@repo/rdx-utils"; + +import type { PrinterConfiguration } from "../domain/printer-configuration.aggregate"; +import { + PrinterConfigurationError, + type PrinterConfigurationInput, +} from "../domain/printer-configuration-values"; +import { PrinterConfigurationValues } from "../domain/value-objects/printer-configuration-values"; + +import type { PrinterContext } from "./printer-configuration.repository"; +import { printerAvailable, printerReferences } from "./printer-configuration-policy"; +export function mapPrinterInput( + input: PrinterConfigurationInput, + context: PrinterContext, + previous?: PrinterConfiguration +) { + const parsed = PrinterConfigurationValues.create(input); + if (!parsed.isSuccess) { + return parsed; + } + const values = parsed.data; + if (previous && !previous.values.sameTarget(values)) { + return Result.fail(new PrinterConfigurationError("PRINTER_TARGET_IMMUTABLE")); + } + if (!printerReferences(values, context)) { + return Result.fail(new PrinterConfigurationError("PRINTER_REFERENCE_NOT_FOUND")); + } + const reconfiguring = + !previous || + previous.values.state !== values.state || + previous.values.connection !== values.connection || + (!previous.values.isDefault && values.isDefault); + if ( + ["pending", "configured"].includes(values.state) && + reconfiguring && + !printerAvailable(values, context) + ) { + return Result.fail(new PrinterConfigurationError("PRINTER_REFERENCE_UNAVAILABLE")); + } + const others = context.configurations.filter( + (item) => !(item.id.equals(previous?.id) || item.values.closed) + ); + if (!values.closed && others.some((item) => item.values.sameTarget(values))) { + return Result.fail(new PrinterConfigurationError("PRINTER_ALREADY_OPEN")); + } + if ( + values.isDefault && + others.some((item) => item.values.isDefault && item.values.profileId.equals(values.profileId)) + ) { + return Result.fail(new PrinterConfigurationError("DEFAULT_PRINTER_EXISTS")); + } + return parsed; +} diff --git a/modules/companies/src/api/application/printer-configuration-policy.ts b/modules/companies/src/api/application/printer-configuration-policy.ts new file mode 100644 index 0000000..37a414b --- /dev/null +++ b/modules/companies/src/api/application/printer-configuration-policy.ts @@ -0,0 +1,21 @@ +import type { PrinterConfigurationValues } from "../domain/value-objects/printer-configuration-values"; + +import type { PrinterContext } from "./printer-configuration.repository"; +export function printerReferences(values: PrinterConfigurationValues, context: PrinterContext) { + const profile = context.profiles.find((item) => item.id.equals(values.profileId)); + return ( + !!profile && + !profile.values.assetId.equals(values.printerId) && + context.assets.some((asset) => asset.id.equals(values.printerId)) + ); +} +export function printerAvailable(values: PrinterConfigurationValues, context: PrinterContext) { + const profile = context.profiles.find((item) => item.id.equals(values.profileId)); + return ( + !!profile && + profile.values.active && + context.people.some((person) => person.id.equals(profile.values.personId) && person.active) && + context.assets.some((asset) => asset.id.equals(profile.values.assetId) && asset.available) && + context.assets.some((asset) => asset.id.equals(values.printerId) && asset.available) + ); +} diff --git a/modules/companies/src/api/application/printer-configuration-response.builder.ts b/modules/companies/src/api/application/printer-configuration-response.builder.ts new file mode 100644 index 0000000..9077c5f --- /dev/null +++ b/modules/companies/src/api/application/printer-configuration-response.builder.ts @@ -0,0 +1,26 @@ +import type { PrinterConfigurationDTO } from "../../common/printer-configurations"; +import type { PrinterConfiguration } from "../domain/printer-configuration.aggregate"; + +import type { PrinterContext } from "./printer-configuration.repository"; +import { printerAvailable } from "./printer-configuration-policy"; +export function buildPrinterResponse( + item: PrinterConfiguration, + context: PrinterContext +): PrinterConfigurationDTO { + return { + id: item.id.toPrimitive(), + companyId: item.companyId.toPrimitive(), + values: item.values.toPrimitive(), + needsReview: + !item.values.closed && + (!printerAvailable(item.values, context) || item.values.state === "removal_pending"), + history: item.history.map((revision) => ({ + id: revision.id.toPrimitive(), + actorId: revision.actorId.toPrimitive(), + actorName: + context.actors.get(revision.actorId.toPrimitive()) ?? revision.actorId.toPrimitive(), + recordedAt: revision.recordedAt, + values: revision.values.toPrimitive(), + })), + }; +} diff --git a/modules/companies/src/api/application/printer-configuration.repository.ts b/modules/companies/src/api/application/printer-configuration.repository.ts new file mode 100644 index 0000000..c881c2f --- /dev/null +++ b/modules/companies/src/api/application/printer-configuration.repository.ts @@ -0,0 +1,25 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import type { Result } from "@repo/rdx-utils"; + +import type { PrinterConfiguration } from "../domain/printer-configuration.aggregate"; +import type { PrinterConfigurationError } from "../domain/printer-configuration-values"; +import type { WorkProfile } from "../domain/work-profile.aggregate"; +export type PrinterContext = Readonly<{ + configurations: readonly PrinterConfiguration[]; + profiles: readonly WorkProfile[]; + people: readonly { id: UniqueID; active: boolean }[]; + assets: readonly { id: UniqueID; available: boolean }[]; + actors: ReadonlyMap; +}>; +export interface IPrinterConfigurationRepository { + get(company: UniqueID): Promise>; + save( + company: UniqueID, + action: (context: PrinterContext) => Result + ): Promise< + Result< + { configuration: PrinterConfiguration; context: PrinterContext }, + PrinterConfigurationError + > + >; +} diff --git a/modules/companies/src/api/application/printer-configuration.use-cases.ts b/modules/companies/src/api/application/printer-configuration.use-cases.ts new file mode 100644 index 0000000..3821b28 --- /dev/null +++ b/modules/companies/src/api/application/printer-configuration.use-cases.ts @@ -0,0 +1,53 @@ +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { PrinterConfiguration } from "../domain/printer-configuration.aggregate"; +import { + PrinterConfigurationError, + type PrinterConfigurationInput, +} from "../domain/printer-configuration-values"; + +import type { IPrinterConfigurationRepository } from "./printer-configuration.repository"; +import { mapPrinterInput } from "./printer-configuration-input.mapper"; +export class PrinterConfigurationUseCases { + constructor(private readonly repository: IPrinterConfigurationRepository) {} + get(companyId: string) { + const company = UniqueID.create(companyId); + return company.isSuccess + ? this.repository.get(company.data) + : Promise.resolve(Result.fail(new PrinterConfigurationError("INVALID_REQUEST"))); + } + save( + companyId: string, + id: string | undefined, + input: PrinterConfigurationInput, + actorId: string + ) { + const company = UniqueID.create(companyId); + const actor = UniqueID.create(actorId); + const identity = id ? UniqueID.create(id) : undefined; + if (!(company.isSuccess && actor.isSuccess) || (identity && !identity.isSuccess)) { + return Promise.resolve(Result.fail(new PrinterConfigurationError("INVALID_REQUEST"))); + } + return this.repository.save(company.data, (context) => { + const previous = identity?.isSuccess + ? context.configurations.find((item) => item.id.equals(identity.data)) + : undefined; + if (id && !previous) { + return Result.fail(new PrinterConfigurationError("PRINTER_CONFIGURATION_NOT_FOUND")); + } + const values = mapPrinterInput(input, context, previous); + if (!values.isSuccess) { + return values; + } + const now = new Date().toISOString(); + const candidate = previous + ? previous.update(values.data, actor.data, now) + : PrinterConfiguration.create(company.data, values.data, actor.data, now); + if (!candidate.isSuccess) { + return candidate; + } + return candidate; + }); + } +} diff --git a/modules/companies/src/api/domain/printer-configuration-revision.entity.ts b/modules/companies/src/api/domain/printer-configuration-revision.entity.ts new file mode 100644 index 0000000..f5f3f13 --- /dev/null +++ b/modules/companies/src/api/domain/printer-configuration-revision.entity.ts @@ -0,0 +1,25 @@ +import { DomainEntity, type UniqueID } from "@repo/rdx-ddd"; + +import type { PrinterConfigurationValues } from "./value-objects/printer-configuration-values"; +export type PrinterConfigurationRevisionProps = Readonly<{ + values: PrinterConfigurationValues; + actorId: UniqueID; + recordedAt: string; +}>; +export class PrinterConfigurationRevision extends DomainEntity { + static create(props: PrinterConfigurationRevisionProps) { + return new PrinterConfigurationRevision(Object.freeze({ ...props })); + } + static rehydrate(props: PrinterConfigurationRevisionProps, id: UniqueID) { + return new PrinterConfigurationRevision(Object.freeze({ ...props }), id); + } + get values() { + return this.props.values; + } + get actorId() { + return this.props.actorId; + } + get recordedAt() { + return this.props.recordedAt; + } +} diff --git a/modules/companies/src/api/domain/printer-configuration-values.ts b/modules/companies/src/api/domain/printer-configuration-values.ts new file mode 100644 index 0000000..0eb5db1 --- /dev/null +++ b/modules/companies/src/api/domain/printer-configuration-values.ts @@ -0,0 +1,24 @@ +import { z } from "zod/v4"; + +import { ConfigurationSchema } from "./access/access-definition"; +export const PrinterConfigurationInputSchema = z.strictObject({ + profileId: z.uuid(), + printerId: z.uuid(), + connection: z.enum(["usb", "network"]), + isDefault: z.boolean(), + state: ConfigurationSchema, + notes: z.string().trim().max(4000).nullable(), +}); +export type PrinterConfigurationInput = z.infer; +export class PrinterConfigurationError extends Error { + constructor(public readonly code: string) { + super(code); + } +} +export const PRINTER_STATE_TRANSITIONS = { + pending: ["pending", "configured", "never_configured"], + configured: ["configured", "removal_pending"], + removal_pending: ["removal_pending", "removed"], + removed: ["removed"], + never_configured: ["never_configured"], +} as const; diff --git a/modules/companies/src/api/domain/printer-configuration.aggregate.ts b/modules/companies/src/api/domain/printer-configuration.aggregate.ts new file mode 100644 index 0000000..2876d5b --- /dev/null +++ b/modules/companies/src/api/domain/printer-configuration.aggregate.ts @@ -0,0 +1,92 @@ +import { AggregateRoot, type UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import { z } from "zod/v4"; + +import { PrinterConfigurationRevision } from "./printer-configuration-revision.entity"; +import { + PRINTER_STATE_TRANSITIONS, + PrinterConfigurationError, +} from "./printer-configuration-values"; +import type { PrinterConfigurationValues } from "./value-objects/printer-configuration-values"; + +type Props = Readonly<{ + companyId: UniqueID; + values: PrinterConfigurationValues; + history: readonly PrinterConfigurationRevision[]; +}>; +export class PrinterConfiguration extends AggregateRoot { + private constructor(props: Props, id?: UniqueID) { + super(Object.freeze({ ...props, history: Object.freeze([...props.history]) }), id); + } + static create( + companyId: UniqueID, + values: PrinterConfigurationValues, + actorId: UniqueID, + recordedAt: string + ): Result { + if ( + !( + z.iso.datetime().safeParse(recordedAt).success && + ["pending", "configured"].includes(values.state) + ) + ) { + return Result.fail(new PrinterConfigurationError("INVALID_PRINTER_TRANSITION")); + } + return Result.ok( + new PrinterConfiguration({ + companyId, + values, + history: [PrinterConfigurationRevision.create({ values, actorId, recordedAt })], + }) + ); + } + static rehydrate(props: Props, id: UniqueID) { + return new PrinterConfiguration(props, id); + } + get companyId() { + return this.props.companyId; + } + get values() { + return this.props.values; + } + get history() { + return this.props.history; + } + update( + values: PrinterConfigurationValues, + actorId: UniqueID, + recordedAt: string + ): Result { + if (this.values.equals(values)) { + return Result.ok(this); + } + if (!this.values.sameTarget(values)) { + return Result.fail(new PrinterConfigurationError("PRINTER_TARGET_IMMUTABLE")); + } + if ( + !PRINTER_STATE_TRANSITIONS[this.values.state].some((state) => state === values.state) || + (this.values.closed && !this.values.equals(values)) + ) { + return Result.fail(new PrinterConfigurationError("INVALID_PRINTER_TRANSITION")); + } + if ( + !z.iso.datetime().safeParse(recordedAt).success || + recordedAt < (this.history.at(-1)?.recordedAt ?? "") + ) { + return Result.fail(new PrinterConfigurationError("INVALID_REQUEST")); + } + return Result.ok( + new PrinterConfiguration( + { + ...this.props, + values, + history: [ + ...this.history, + PrinterConfigurationRevision.create({ values, actorId, recordedAt }), + ], + }, + this.id + ) + ); + } +} diff --git a/modules/companies/src/api/domain/printer-configuration.test.ts b/modules/companies/src/api/domain/printer-configuration.test.ts new file mode 100644 index 0000000..4b1e1be --- /dev/null +++ b/modules/companies/src/api/domain/printer-configuration.test.ts @@ -0,0 +1,213 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { UniqueID } from "@repo/rdx-ddd"; + +import type { PrinterContext } from "../application/printer-configuration.repository"; +import { mapPrinterInput } from "../application/printer-configuration-input.mapper"; +import { buildPrinterResponse } from "../application/printer-configuration-response.builder"; +import { PrinterConfigurationPersistenceMapper } from "../infrastructure/persistence/printer-configuration-persistence.mapper"; + +import { PrinterConfiguration } from "./printer-configuration.aggregate"; +import { + type PrinterConfigurationInput, + PrinterConfigurationInputSchema, +} from "./printer-configuration-values"; +import { PrinterConfigurationValues } from "./value-objects/printer-configuration-values"; +import { WorkProfileValues } from "./value-objects/work-profile-values"; +import { WorkProfile } from "./work-profile.aggregate"; + +const company = UniqueID.generateNewID(); +const actor = UniqueID.generateNewID(); +const person = UniqueID.generateNewID(); +const host = UniqueID.generateNewID(); +const printer = UniqueID.generateNewID(); +const secondPrinter = UniqueID.generateNewID(); +const date = "2026-10-05T12:00:00.000Z"; +function fixture() { + const values = WorkProfileValues.create({ + name: "Perfil Ana", + personId: person.toPrimitive(), + assetId: host.toPrimitive(), + accountId: null, + active: true, + notes: null, + }); + assert.ok(values.isSuccess); + const profile = WorkProfile.create(company, values.data, actor, date); + assert.ok(profile.isSuccess); + const input: PrinterConfigurationInput = { + profileId: profile.data.id.toPrimitive(), + printerId: printer.toPrimitive(), + connection: "usb", + state: "pending", + isDefault: false, + notes: null, + }; + const context: PrinterContext = { + configurations: [], + profiles: [profile.data], + people: [{ id: person, active: true }], + assets: [host, printer, secondPrinter].map((id) => ({ id, available: true })), + actors: new Map([[actor.toPrimitive(), "Técnico"]]), + }; + return { input, context }; +} +function values(input: PrinterConfigurationInput) { + const result = PrinterConfigurationValues.create(input); + assert.ok(result.isSuccess); + return result.data; +} +function create(input: PrinterConfigurationInput) { + const result = PrinterConfiguration.create(company, values(input), actor, date); + assert.ok(result.isSuccess); + return result.data; +} +test("impresoras: contratos estrictos, notas opcionales y predeterminada solo vigente", () => { + const { input } = fixture(); + assert.equal( + PrinterConfigurationInputSchema.safeParse({ ...input, actorId: actor.toPrimitive() }).success, + false + ); + assert.equal( + PrinterConfigurationInputSchema.safeParse({ ...input, connection: "bluetooth" }).success, + false + ); + assert.equal( + PrinterConfigurationValues.create({ ...input, isDefault: true, state: "removed" }).isSuccess, + false + ); + assert.equal(values({ ...input, notes: " " }).toPrimitive().notes, null); + assert.equal( + PrinterConfigurationValues.create({ ...input, notes: "a".repeat(4001) }).isSuccess, + false + ); +}); +test("impresoras: UUID, historial, igualdad semántica, destino fijo y retirada terminal", () => { + const { input } = fixture(); + const root = create(input); + assert.equal(root.id.toPrimitive()[14], "7"); + assert.equal(root.history[0]?.id.toPrimitive()[14], "7"); + const unchanged = root.update(values({ ...input, notes: " " }), actor, date); + assert.ok(unchanged.isSuccess); + assert.equal(unchanged.data, root); + const configured = root.update( + values({ ...input, state: "configured", isDefault: true }), + actor, + "2026-10-05T13:00:00.000Z" + ); + assert.ok(configured.isSuccess); + assert.ok(configured.data.id.equals(root.id)); + assert.equal(configured.data.history.length, 2); + assert.equal(configured.data.history[0]?.values.isDefault, false); + const moved = configured.data.update( + values({ ...input, state: "configured", printerId: secondPrinter.toPrimitive() }), + actor, + date + ); + assert.ok(!moved.isSuccess); + assert.equal(moved.error.code, "PRINTER_TARGET_IMMUTABLE"); + const removal = configured.data.update( + values({ ...input, state: "removal_pending" }), + actor, + "2026-10-05T14:00:00.000Z" + ); + assert.ok(removal.isSuccess); + const retired = removal.data.update( + values({ ...input, state: "removed" }), + actor, + "2026-10-05T15:00:00.000Z" + ); + assert.ok(retired.isSuccess); + assert.equal( + retired.data.update(values(input), actor, "2026-10-05T16:00:00.000Z").isSuccess, + false + ); + assert.equal( + retired.data.update( + values({ ...input, state: "removed", notes: "edit" }), + actor, + "2026-10-05T16:00:00.000Z" + ).isSuccess, + false + ); + assert.equal( + root.update(values({ ...input, state: "configured" }), actor, "2026-10-05T11:00:00.000Z") + .isSuccess, + false + ); + assert.equal( + PrinterConfiguration.create(company, values({ ...input, state: "removed" }), actor, date) + .isSuccess, + false + ); +}); +test("impresoras: duplicados por perfil, predeterminada única y destinos del ámbito", () => { + const { input, context } = fixture(); + const first = create({ ...input, isDefault: true }); + const loaded = { ...context, configurations: [first] }; + const duplicate = mapPrinterInput(input, loaded); + assert.ok(!duplicate.isSuccess); + assert.equal(duplicate.error.code, "PRINTER_ALREADY_OPEN"); + const defaultConflict = mapPrinterInput( + { ...input, printerId: secondPrinter.toPrimitive(), isDefault: true }, + loaded + ); + assert.ok(!defaultConflict.isSuccess); + assert.equal(defaultConflict.error.code, "DEFAULT_PRINTER_EXISTS"); + assert.ok( + mapPrinterInput({ ...input, printerId: secondPrinter.toPrimitive() }, loaded).isSuccess + ); + assert.equal( + mapPrinterInput({ ...input, printerId: host.toPrimitive() }, context).isSuccess, + false + ); + assert.equal( + mapPrinterInput({ ...input, printerId: UniqueID.generateNewID().toPrimitive() }, context) + .isSuccess, + false + ); +}); +test("impresoras: indisponibilidad señala revisión y permite notas y retirada", () => { + const { input, context } = fixture(); + const root = create({ ...input, state: "configured", isDefault: true }); + const inactive = { ...context, configurations: [root], people: [{ id: person, active: false }] }; + assert.equal(mapPrinterInput(input, inactive).isSuccess, false); + assert.ok( + mapPrinterInput({ ...root.values.toPrimitive(), notes: "Retirar al salir" }, inactive, root) + .isSuccess + ); + assert.ok(mapPrinterInput({ ...input, state: "removal_pending" }, inactive, root).isSuccess); + assert.equal(buildPrinterResponse(root, inactive).needsReview, true); + const unavailable = { + ...context, + assets: context.assets.map((asset) => ({ ...asset, available: !asset.id.equals(printer) })), + }; + assert.equal(mapPrinterInput(input, unavailable).isSuccess, false); + assert.equal( + mapPrinterInput({ ...input, connection: "network" }, inactive, root).isSuccess, + false + ); +}); +test("impresoras: rehidratación conserva identidades y detecta corrupción", () => { + const { input } = fixture(); + const root = create(input); + const mapper = new PrinterConfigurationPersistenceMapper(); + const record = mapper.toPersistence(root); + const rehydrated = mapper.toDomain(record.root, record.revisions); + assert.ok(rehydrated.id.equals(root.id)); + assert.ok(rehydrated.values.equals(root.values)); + assert.throws(() => mapper.toDomain({ ...record.root, currentKey: null }, record.revisions)); + assert.throws(() => + mapper.toDomain( + record.root, + record.revisions.map((row) => ({ ...row, position: 1 })) + ) + ); + assert.throws(() => + mapper.toDomain( + record.root, + record.revisions.map((row) => ({ ...row, configurationId: company.toPrimitive() })) + ) + ); +}); diff --git a/modules/companies/src/api/domain/value-objects/printer-configuration-values.ts b/modules/companies/src/api/domain/value-objects/printer-configuration-values.ts new file mode 100644 index 0000000..be04dd5 --- /dev/null +++ b/modules/companies/src/api/domain/value-objects/printer-configuration-values.ts @@ -0,0 +1,72 @@ +import { UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { + PrinterConfigurationError, + type PrinterConfigurationInput, + PrinterConfigurationInputSchema, +} from "../printer-configuration-values"; +export class PrinterConfigurationValues { + private constructor( + public readonly profileId: UniqueID, + public readonly printerId: UniqueID, + public readonly connection: PrinterConfigurationInput["connection"], + public readonly isDefault: boolean, + public readonly state: PrinterConfigurationInput["state"], + public readonly notes: Maybe + ) { + Object.freeze(this); + } + static create( + input: PrinterConfigurationInput + ): Result { + const parsed = PrinterConfigurationInputSchema.safeParse(input); + if (!parsed.success) { + return Result.fail(new PrinterConfigurationError("INVALID_REQUEST")); + } + const data = parsed.data; + const profile = UniqueID.create(data.profileId); + const printer = UniqueID.create(data.printerId); + if ( + !(profile.isSuccess && printer.isSuccess) || + (data.isDefault && !["pending", "configured"].includes(data.state)) + ) { + return Result.fail(new PrinterConfigurationError("INVALID_REQUEST")); + } + return Result.ok( + new PrinterConfigurationValues( + profile.data, + printer.data, + data.connection, + data.isDefault, + data.state, + data.notes ? Maybe.some(data.notes) : Maybe.none() + ) + ); + } + get closed() { + return this.state === "removed" || this.state === "never_configured"; + } + sameTarget(other: PrinterConfigurationValues) { + return this.profileId.equals(other.profileId) && this.printerId.equals(other.printerId); + } + equals(other: PrinterConfigurationValues) { + return ( + this.sameTarget(other) && + this.connection === other.connection && + this.isDefault === other.isDefault && + this.state === other.state && + this.toPrimitive().notes === other.toPrimitive().notes + ); + } + toPrimitive(): PrinterConfigurationInput { + return { + profileId: this.profileId.toPrimitive(), + printerId: this.printerId.toPrimitive(), + connection: this.connection, + isDefault: this.isDefault, + state: this.state, + notes: this.notes.getOrUndefined() ?? null, + }; + } +} diff --git a/modules/companies/src/api/infrastructure/http/access.routes.ts b/modules/companies/src/api/infrastructure/http/access.routes.ts index b7c9c57..0c8ff5f 100644 --- a/modules/companies/src/api/infrastructure/http/access.routes.ts +++ b/modules/companies/src/api/infrastructure/http/access.routes.ts @@ -50,7 +50,8 @@ function actorId(request: Request) { export function registerAccessRoutes( app: Application, useCases: AccessUseCases, - authenticate: RequestHandler + authenticate: RequestHandler, + legacyResourceWrites = false ) { const router = Router(); router.get("/:scope", (request, response, next) => { @@ -74,6 +75,10 @@ export function registerAccessRoutes( sendError(response, "INVALID_REQUEST", 400); return; } + if (!legacyResourceWrites && body.data.values.kind === "resource") { + sendError(response, "RESOURCE_MOVED", 409); + return; + } const id = z.object({ id: z.uuid() }).safeParse(params.data); const identity = id.success ? id.data.id : undefined; useCases diff --git a/modules/companies/src/api/infrastructure/http/mailbox.routes.ts b/modules/companies/src/api/infrastructure/http/mailbox.routes.ts index 01dd91c..7f6de30 100644 --- a/modules/companies/src/api/infrastructure/http/mailbox.routes.ts +++ b/modules/companies/src/api/infrastructure/http/mailbox.routes.ts @@ -14,7 +14,8 @@ function fail(response: Response, code: string) { export function registerMailboxRoutes( app: Application, useCases: MailboxUseCases, - authenticate: RequestHandler + authenticate: RequestHandler, + legacyResourceWrites = false ) { const router = Router(); router.get("/:companyId", (request, response, next) => { @@ -61,6 +62,10 @@ export function registerMailboxRoutes( fail(response, "INVALID_REQUEST"); return; } + if (!legacyResourceWrites && "address" in body.data.values) { + fail(response, "RESOURCE_MOVED"); + return; + } const id = "id" in params.data && typeof params.data.id === "string" ? params.data.id : undefined; useCases diff --git a/modules/companies/src/api/infrastructure/http/printer-configuration.routes.ts b/modules/companies/src/api/infrastructure/http/printer-configuration.routes.ts new file mode 100644 index 0000000..f5500f5 --- /dev/null +++ b/modules/companies/src/api/infrastructure/http/printer-configuration.routes.ts @@ -0,0 +1,81 @@ +import { type Application, type RequestHandler, type Response, Router } from "express"; +import { z } from "zod/v4"; + +import { + PrinterItemParamsSchema, + PrinterParamsSchema, + SavePrinterConfigurationRequestSchema, +} from "../../../common/printer-configurations"; +import type { PrinterConfigurationUseCases } from "../../application/printer-configuration.use-cases"; +import { buildPrinterResponse } from "../../application/printer-configuration-response.builder"; + +function fail(response: Response, code: string) { + response + .status(code === "INVALID_REQUEST" ? 400 : code.endsWith("NOT_FOUND") ? 404 : 409) + .json({ code, requestId: response.getHeader("x-request-id") }); +} +export function registerPrinterConfigurationRoutes( + app: Application, + useCases: PrinterConfigurationUseCases, + authenticate: RequestHandler +) { + const router = Router(); + router.get("/:companyId", (request, response, next) => { + const params = PrinterParamsSchema.safeParse(request.params); + if (!(params.success && z.strictObject({}).safeParse(request.query).success)) { + fail(response, "INVALID_REQUEST"); + return; + } + useCases + .get(params.data.companyId) + .then((result) => { + if (!result.isSuccess) { + fail(response, result.error.code); + return; + } + response.json({ + configurations: result.data.configurations.map((item) => + buildPrinterResponse(item, result.data) + ), + }); + }) + .catch(next); + }); + for (const path of ["/:companyId", "/:companyId/:id/update"]) { + router.post(path, (request, response, next) => { + const params = ( + path.endsWith("update") ? PrinterItemParamsSchema : PrinterParamsSchema + ).safeParse(request.params); + const body = SavePrinterConfigurationRequestSchema.safeParse(request.body); + const actor = z + .object({ userId: z.uuid() }) + .safeParse("session" in request ? request.session : undefined); + if ( + !( + params.success && + body.success && + actor.success && + z.strictObject({}).safeParse(request.query).success + ) + ) { + fail(response, "INVALID_REQUEST"); + return; + } + const id = + "id" in params.data && typeof params.data.id === "string" ? params.data.id : undefined; + useCases + .save(params.data.companyId, id, body.data.values, actor.data.userId) + .then((result) => { + if (!result.isSuccess) { + fail(response, result.error.code); + return; + } + response.status(id ? 200 : 201).json({ + configuration: buildPrinterResponse(result.data.configuration, result.data.context), + }); + }) + .catch(next); + }); + } + app.use("/api/printer-configurations", authenticate, router); +} diff --git a/modules/companies/src/api/infrastructure/persistence/printer-configuration-migration.ts b/modules/companies/src/api/infrastructure/persistence/printer-configuration-migration.ts new file mode 100644 index 0000000..0933541 --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/printer-configuration-migration.ts @@ -0,0 +1,45 @@ +import { DataTypes, QueryTypes, type Sequelize } from "sequelize"; +export async function migratePrinterConfigurations(database: Sequelize) { + const q = database.getQueryInterface(); + const tables = await database.query<{ name: string }>( + "SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()", + { type: QueryTypes.SELECT } + ); + const fk = (table: string) => ({ + type: DataTypes.UUID, + allowNull: false, + references: { model: table, key: "id" }, + onDelete: "RESTRICT", + }); + if (!tables.some((t) => t.name === "printer_configurations")) { + await q.createTable("printer_configurations", { + id: { type: DataTypes.UUID, primaryKey: true }, + companyId: fk("companies"), + profileId: fk("work_profiles"), + printerId: fk("assets"), + values: { type: DataTypes.JSON, allowNull: false }, + currentKey: { type: DataTypes.STRING(80), allowNull: true, unique: true }, + defaultKey: { type: DataTypes.STRING(40), allowNull: true, unique: true }, + }); + } + if (!tables.some((t) => t.name === "printer_configuration_revisions")) { + await q.createTable("printer_configuration_revisions", { + id: { type: DataTypes.UUID, primaryKey: true }, + configurationId: fk("printer_configurations"), + position: { type: DataTypes.INTEGER, allowNull: false }, + values: { type: DataTypes.JSON, allowNull: false }, + actorId: fk("users"), + recordedAt: { type: DataTypes.STRING(24), allowNull: false }, + }); + } + const indexes = await database.query<{ name: string }>( + "SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='printer_configuration_revisions'", + { type: QueryTypes.SELECT } + ); + if (!indexes.some((item) => item.name === "printer_configuration_position")) { + await q.addIndex("printer_configuration_revisions", ["configurationId", "position"], { + unique: true, + name: "printer_configuration_position", + }); + } +} diff --git a/modules/companies/src/api/infrastructure/persistence/printer-configuration-persistence.mapper.ts b/modules/companies/src/api/infrastructure/persistence/printer-configuration-persistence.mapper.ts new file mode 100644 index 0000000..d121299 --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/printer-configuration-persistence.mapper.ts @@ -0,0 +1,122 @@ +import { z } from "zod/v4"; + +import { PrinterConfiguration } from "../../domain/printer-configuration.aggregate"; +import { PrinterConfigurationRevision } from "../../domain/printer-configuration-revision.entity"; +import { + PRINTER_STATE_TRANSITIONS, + PrinterConfigurationInputSchema, +} from "../../domain/printer-configuration-values"; +import { PrinterConfigurationValues } from "../../domain/value-objects/printer-configuration-values"; + +import { persistedJson, profileIdentity } from "./work-profile-persistence.mapper"; +export type PrinterRow = { + id: string; + companyId: string; + profileId: string; + printerId: string; + values: unknown; + currentKey: string | null; + defaultKey: string | null; +}; +export type PrinterRevisionRow = { + id: string; + configurationId: string; + position: number; + values: unknown; + actorId: string; + recordedAt: string; +}; +function mapped(raw: unknown) { + const parsed = PrinterConfigurationValues.create( + PrinterConfigurationInputSchema.parse(persistedJson(raw)) + ); + if (!parsed.isSuccess) { + throw new Error("Valores de impresora persistidos inconsistentes."); + } + return parsed.data; +} +export class PrinterConfigurationPersistenceMapper { + toDomain(row: PrinterRow, revisions: readonly PrinterRevisionRow[]) { + const values = mapped(row.values); + const ordered = [...revisions].sort((a, b) => a.position - b.position); + const history = ordered.map((revision, index) => { + const value = mapped(revision.values); + const previous = ordered[index - 1]; + if ( + revision.configurationId !== row.id || + revision.position !== index || + !z.iso.datetime().safeParse(revision.recordedAt).success || + revision.recordedAt < (previous?.recordedAt ?? "") || + !values.sameTarget(value) + ) { + throw new Error("Historial de impresora persistido inconsistente."); + } + return PrinterConfigurationRevision.rehydrate( + { + values: value, + actorId: profileIdentity(revision.actorId), + recordedAt: revision.recordedAt, + }, + profileIdentity(revision.id) + ); + }); + if (!(history.length && ["pending", "configured"].includes(history[0]?.values.state ?? ""))) { + throw new Error("Estado inicial de impresora persistido inconsistente."); + } + for (const [index, revision] of history.entries()) { + const previous = history[index - 1]?.values; + if ( + previous && + (!PRINTER_STATE_TRANSITIONS[previous.state].some( + (state) => state === revision.values.state + ) || + (previous.closed && !previous.equals(revision.values))) + ) { + throw new Error("Estados de impresora históricos inconsistentes."); + } + } + const keys = printerKeys(values); + if ( + !history.at(-1)?.values.equals(values) || + row.profileId !== values.profileId.toPrimitive() || + row.printerId !== values.printerId.toPrimitive() || + row.currentKey !== keys.currentKey || + row.defaultKey !== keys.defaultKey + ) { + throw new Error("Referencias de impresora persistidas inconsistentes."); + } + return PrinterConfiguration.rehydrate( + { companyId: profileIdentity(row.companyId), values, history }, + profileIdentity(row.id) + ); + } + toPersistence(item: PrinterConfiguration) { + const values = item.values.toPrimitive(); + return { + root: { + id: item.id.toPrimitive(), + companyId: item.companyId.toPrimitive(), + profileId: values.profileId, + printerId: values.printerId, + values: JSON.stringify(values), + ...printerKeys(item.values), + }, + revisions: item.history.map((revision, position) => ({ + id: revision.id.toPrimitive(), + configurationId: item.id.toPrimitive(), + position, + actorId: revision.actorId.toPrimitive(), + recordedAt: revision.recordedAt, + values: JSON.stringify(revision.values.toPrimitive()), + })), + }; + } +} +function printerKeys(values: PrinterConfigurationValues) { + return { + currentKey: values.closed + ? null + : `${values.profileId.toPrimitive()}:${values.printerId.toPrimitive()}`, + defaultKey: values.isDefault ? values.profileId.toPrimitive() : null, + }; +} diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize-printer-configuration.repository.ts b/modules/companies/src/api/infrastructure/persistence/sequelize-printer-configuration.repository.ts new file mode 100644 index 0000000..18296d5 --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/sequelize-printer-configuration.repository.ts @@ -0,0 +1,133 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import { QueryTypes, type Sequelize, type Transaction } from "sequelize"; + +import type { + IPrinterConfigurationRepository, + PrinterContext, +} from "../../application/printer-configuration.repository"; +import { printerReferences } from "../../application/printer-configuration-policy"; +import type { IWorkProfileInventoryService } from "../../application/work-profile.repository"; +import { PrinterConfigurationError } from "../../domain/printer-configuration-values"; + +import { + PrinterConfigurationPersistenceMapper, + type PrinterRevisionRow, + type PrinterRow, +} from "./printer-configuration-persistence.mapper"; +import { + type ProfileRevisionRow, + type ProfileRow, + WorkProfilePersistenceMapper, + profileIdentity, +} from "./work-profile-persistence.mapper"; +export class SequelizePrinterConfigurationRepository implements IPrinterConfigurationRepository { + private readonly mapper = new PrinterConfigurationPersistenceMapper(); + constructor( + private readonly database: Sequelize, + private readonly inventory: IWorkProfileInventoryService + ) {} + private async load( + company: UniqueID, + transaction: Transaction, + write: boolean + ): Promise> { + const replacements = { companyId: company.toPrimitive() }; + const options = { replacements, transaction, type: QueryTypes.SELECT }; + const companies = await this.database.query<{ id: string }>( + `SELECT id FROM companies WHERE id=:companyId${write ? " FOR UPDATE" : ""}`, + { ...options, type: QueryTypes.SELECT } + ); + if (!companies.length) { + return Result.fail(new PrinterConfigurationError("COMPANY_NOT_FOUND")); + } + const rows = await this.database.query( + "SELECT * FROM printer_configurations WHERE companyId=:companyId", + { ...options, type: QueryTypes.SELECT } + ); + const revisions = await this.database.query( + "SELECT r.* FROM printer_configuration_revisions r JOIN printer_configurations m ON m.id=r.configurationId WHERE m.companyId=:companyId", + { ...options, type: QueryTypes.SELECT } + ); + const profiles = await this.database.query( + "SELECT * FROM work_profiles WHERE companyId=:companyId", + { ...options, type: QueryTypes.SELECT } + ); + const profileRevisions = await this.database.query( + "SELECT r.* FROM work_profile_revisions r JOIN work_profiles p ON p.id=r.profileId WHERE p.companyId=:companyId", + { ...options, type: QueryTypes.SELECT } + ); + const people = await this.database.query<{ id: string; active: boolean }>( + "SELECT id,active FROM company_contacts WHERE companyId=:companyId", + { ...options, type: QueryTypes.SELECT } + ); + const actors = await this.database.query<{ id: string; name: string }>( + "SELECT id,name FROM users", + { transaction, type: QueryTypes.SELECT } + ); + const context: PrinterContext = { + configurations: rows.map((row) => + this.mapper.toDomain( + row, + revisions.filter((r) => r.configurationId === row.id) + ) + ), + profiles: profiles.map((row) => + new WorkProfilePersistenceMapper().toDomain( + row, + profileRevisions.filter((r) => r.profileId === row.id) + ) + ), + people: people.map((item) => ({ + id: profileIdentity(item.id), + active: Boolean(item.active), + })), + assets: await this.inventory.list(company), + actors: new Map(actors.map((actor) => [actor.id, actor.name])), + }; + if ( + context.profiles.some( + (profile) => + !( + context.people.some((person) => person.id.equals(profile.values.personId)) && + context.assets.some((asset) => asset.id.equals(profile.values.assetId)) + ) + ) || + context.configurations.some((mapping) => !printerReferences(mapping.values, context)) + ) { + throw new Error("Ámbito de impresora persistido inconsistente."); + } + return Result.ok(context); + } + get(company: UniqueID) { + return this.database.transaction((t) => this.load(company, t, false)); + } + save(company: UniqueID, action: Parameters[1]) { + return this.database.transaction(async (transaction) => { + const loaded = await this.load(company, transaction, true); + if (!loaded.isSuccess) { + return loaded; + } + const candidate = action(loaded.data); + if (!candidate.isSuccess) { + return candidate; + } + const previous = loaded.data.configurations.find((item) => item.id.equals(candidate.data.id)); + if (previous !== candidate.data) { + const record = this.mapper.toPersistence(candidate.data); + await this.database.query( + "INSERT INTO printer_configurations (id,companyId,profileId,printerId,`values`,currentKey,defaultKey) VALUES (:id,:companyId,:profileId,:printerId,:values,:currentKey,:defaultKey) ON DUPLICATE KEY UPDATE `values`=VALUES(`values`),currentKey=VALUES(currentKey),defaultKey=VALUES(defaultKey)", + { replacements: record.root, transaction } + ); + const stored = new Set(previous?.history.map((r) => r.id.toPrimitive())); + for (const revision of record.revisions.filter((r) => !stored.has(r.id))) { + await this.database.query( + "INSERT INTO printer_configuration_revisions (id,configurationId,position,actorId,recordedAt,`values`) VALUES (:id,:configurationId,:position,:actorId,:recordedAt,:values)", + { replacements: revision, transaction } + ); + } + } + return Result.ok({ configuration: candidate.data, context: loaded.data }); + }); + } +} diff --git a/modules/companies/src/api/module.ts b/modules/companies/src/api/module.ts index 556fd10..6aeac45 100644 --- a/modules/companies/src/api/module.ts +++ b/modules/companies/src/api/module.ts @@ -1,22 +1,22 @@ -import {FolderMappingUseCases} from "./application/folder-mapping.use-cases"; -import {registerFolderMappingRoutes} from "./infrastructure/http/folder-mapping.routes"; -import {migrateFolderMappings} from "./infrastructure/persistence/folder-mapping-migration"; -import {SequelizeFolderMappingRepository} from "./infrastructure/persistence/sequelize-folder-mapping.repository"; import type { RequestHandler } from "express"; import type { Sequelize } from "sequelize"; import { AccessUseCases } from "./application/access.use-cases"; import { CompanyUseCases } from "./application/company.use-cases"; import { CompanyInputMapper } from "./application/company-input.mapper"; +import { FolderMappingUseCases } from "./application/folder-mapping.use-cases"; import type { IMailInventoryService } from "./application/mail-configuration.repository"; import { MailConfigurationUseCases } from "./application/mail-configuration.use-cases"; import { MailboxUseCases } from "./application/mailbox.use-cases"; +import { PrinterConfigurationUseCases } from "./application/printer-configuration.use-cases"; import type { IWorkProfileInventoryService } from "./application/work-profile.repository"; import { WorkProfileUseCases } from "./application/work-profile.use-cases"; import { registerAccessRoutes } from "./infrastructure/http/access.routes"; import { registerCompanyRoutes } from "./infrastructure/http/company.routes"; +import { registerFolderMappingRoutes } from "./infrastructure/http/folder-mapping.routes"; import { registerMailRoutes } from "./infrastructure/http/mail-configuration.routes"; import { registerMailboxRoutes } from "./infrastructure/http/mailbox.routes"; +import { registerPrinterConfigurationRoutes } from "./infrastructure/http/printer-configuration.routes"; import { registerWorkProfileRoutes } from "./infrastructure/http/work-profile.routes"; import { AccessPersistenceMapper, @@ -25,18 +25,26 @@ import { } from "./infrastructure/persistence/access-persistence"; import { initializeCompanyModels } from "./infrastructure/persistence/company.model"; import { CompanyPersistenceMapper } from "./infrastructure/persistence/company-persistence.mapper"; +import { migrateFolderMappings } from "./infrastructure/persistence/folder-mapping-migration"; import { migrateMailConfigurations } from "./infrastructure/persistence/mail-configuration-migration"; import { migrateMailboxes } from "./infrastructure/persistence/mailbox-migration"; import { migrateCompanies } from "./infrastructure/persistence/migrate-companies"; import { migratePeople } from "./infrastructure/persistence/migrate-people"; import { initializePeopleModels } from "./infrastructure/persistence/people.model"; +import { migratePrinterConfigurations } from "./infrastructure/persistence/printer-configuration-migration"; import { SequelizeCompanyRepository } from "./infrastructure/persistence/sequelize-company.repository"; +import { SequelizeFolderMappingRepository } from "./infrastructure/persistence/sequelize-folder-mapping.repository"; import { SequelizeMailConfigurationRepository } from "./infrastructure/persistence/sequelize-mail-configuration.repository"; import { SequelizeMailboxRepository } from "./infrastructure/persistence/sequelize-mailbox.repository"; +import { SequelizePrinterConfigurationRepository } from "./infrastructure/persistence/sequelize-printer-configuration.repository"; import { SequelizeWorkProfileRepository } from "./infrastructure/persistence/sequelize-work-profile.repository"; import { migrateWorkProfiles } from "./infrastructure/persistence/work-profile-migration"; -export async function createCompanyModule(database: Sequelize, authenticate: RequestHandler) { +export async function createCompanyModule( + database: Sequelize, + authenticate: RequestHandler, + legacyResourceWrites = false +) { await migrateCompanies(database); await migratePeople(database); initializeCompanyModels(database); @@ -51,7 +59,13 @@ export async function createCompanyModule(database: Sequelize, authenticate: Req initializeProfiles: async (inventory: IWorkProfileInventoryService) => { await migrateWorkProfiles(database); await migrateFolderMappings(database); - const folders=new FolderMappingUseCases(new SequelizeFolderMappingRepository(database,inventory)); + await migratePrinterConfigurations(database); + const printers = new PrinterConfigurationUseCases( + new SequelizePrinterConfigurationRepository(database, inventory) + ); + const folders = new FolderMappingUseCases( + new SequelizeFolderMappingRepository(database, inventory) + ); const profiles = new WorkProfileUseCases( new SequelizeWorkProfileRepository(database, inventory) ); @@ -59,6 +73,7 @@ export async function createCompanyModule(database: Sequelize, authenticate: Req register: (app: Parameters[0]) => { registerWorkProfileRoutes(app, profiles, authenticate); registerFolderMappingRoutes(app, folders, authenticate); + registerPrinterConfigurationRoutes(app, printers, authenticate); }, }; }, @@ -74,13 +89,13 @@ export async function createCompanyModule(database: Sequelize, authenticate: Req return { register: (app: Parameters[0]) => { registerMailRoutes(app, mail, authenticate); - registerMailboxRoutes(app, mailboxes, authenticate); + registerMailboxRoutes(app, mailboxes, authenticate, legacyResourceWrites); }, }; }, register: (app: Parameters[0]) => { registerCompanyRoutes(app, useCases, authenticate); - registerAccessRoutes(app, access, authenticate); + registerAccessRoutes(app, access, authenticate, legacyResourceWrites); }, }; } diff --git a/modules/companies/src/common/index.ts b/modules/companies/src/common/index.ts index c7c7e5b..23f2b5b 100644 --- a/modules/companies/src/common/index.ts +++ b/modules/companies/src/common/index.ts @@ -5,8 +5,8 @@ export * from "./dto/response/company.response.dto"; export * from "./dto/shared/access.dto"; export * from "./dto/shared/company.dto"; export * from "./dto/shared/people.dto"; +export * from "./folder-mappings"; export * from "./mail-configurations"; export * from "./mailboxes"; +export * from "./printer-configurations"; export * from "./work-profiles"; - -export * from "./folder-mappings"; diff --git a/modules/companies/src/common/printer-configurations.ts b/modules/companies/src/common/printer-configurations.ts new file mode 100644 index 0000000..d58d3af --- /dev/null +++ b/modules/companies/src/common/printer-configurations.ts @@ -0,0 +1,38 @@ +import { z } from "zod/v4"; + +import { PrinterConfigurationInputSchema } from "../api/domain/printer-configuration-values"; + +export type { PrinterConfigurationInput } from "../api/domain/printer-configuration-values"; +export { + PRINTER_STATE_TRANSITIONS, + PrinterConfigurationInputSchema, +} from "../api/domain/printer-configuration-values"; +export const PrinterParamsSchema = z.strictObject({ companyId: z.uuid() }); +export const PrinterItemParamsSchema = PrinterParamsSchema.extend({ id: z.uuid() }); +export const SavePrinterConfigurationRequestSchema = z.strictObject({ + values: PrinterConfigurationInputSchema, +}); +export const PrinterConfigurationSchema = z.strictObject({ + id: z.uuid(), + companyId: z.uuid(), + values: PrinterConfigurationInputSchema, + needsReview: z.boolean(), + history: z + .array( + z.strictObject({ + id: z.uuid(), + actorId: z.uuid(), + actorName: z.string().min(1), + recordedAt: z.iso.datetime(), + values: PrinterConfigurationInputSchema, + }) + ) + .min(1), +}); +export const PrinterConfigurationsResponseSchema = z.strictObject({ + configurations: z.array(PrinterConfigurationSchema), +}); +export const PrinterConfigurationResponseSchema = z.strictObject({ + configuration: PrinterConfigurationSchema, +}); +export type PrinterConfigurationDTO = z.infer; diff --git a/modules/companies/src/web/access/access-form.tsx b/modules/companies/src/web/access/access-form.tsx index b47df30..788fe99 100644 --- a/modules/companies/src/web/access/access-form.tsx +++ b/modules/companies/src/web/access/access-form.tsx @@ -1,3 +1,4 @@ +import { ResourceEditor } from "@metis/resources/client"; import { Button } from "@repo/shadcn-ui/components/button"; import { FormActions, FormDialog } from "@repo/shadcn-ui/components/form-dialog"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -26,7 +27,8 @@ import { EntityHelp } from "./entity-help"; import { QuickCreateDialog } from "./quick-create-dialog"; import type { QuickCreatePlan } from "./quick-create-plan"; import { matchesSubmittedValues, quickCreatePlan } from "./quick-create-plan"; -export function AccessForm({ + +function AccessFormInternal({ scope, kind, item, @@ -330,3 +332,25 @@ function AccessQuickEditor({ ); } + +export function AccessForm(props: Parameters[0]) { + if (props.kind === "resource") { + const type = props.initialValues?.resourceTypeId; + return ( + props.onSaved(id)} + resourceId={props.item?.id} + /> + ); + } + return ; +} diff --git a/modules/companies/src/web/access/access-workspace.tsx b/modules/companies/src/web/access/access-workspace.tsx index 56f47cd..03bea32 100644 --- a/modules/companies/src/web/access/access-workspace.tsx +++ b/modules/companies/src/web/access/access-workspace.tsx @@ -55,7 +55,6 @@ export function AccessWorkspacePage({ "account_person", "group", "membership", - "resource", "grant", "need", "coverage", diff --git a/modules/companies/src/web/access/access.test.tsx b/modules/companies/src/web/access/access.test.tsx index 19584aa..1558a1b 100644 --- a/modules/companies/src/web/access/access.test.tsx +++ b/modules/companies/src/web/access/access.test.tsx @@ -262,14 +262,7 @@ test("los formularios ofrecen altas relacionadas y el segundo modal evita altas ); - for (const kind of [ - "permission", - "account_person", - "resource", - "grant", - "need", - "group", - ] as const) { + for (const kind of ["permission", "account_person", "grant", "need", "group"] as const) { assert.ok(render(kind).includes('data-slot="button-group"'), kind); assert.equal(render(kind, false).includes('data-slot="button-group"'), false, kind); } diff --git a/modules/companies/src/web/components/company-detail.tsx b/modules/companies/src/web/components/company-detail.tsx index b8fe5c2..c79078f 100644 --- a/modules/companies/src/web/components/company-detail.tsx +++ b/modules/companies/src/web/components/company-detail.tsx @@ -23,7 +23,8 @@ import { AccessWorkspacePage } from "../access/access-workspace"; import { companyApi, companyKeys } from "../api/company-api"; import { FolderMappingsPage } from "../folders/folder-mappings-page"; import { MailConfigurationsPage } from "../mail/mail-configurations-page"; -import { MailboxesPage } from "../mail/mailboxes-page"; +import { MailboxRelations } from "../mail/mailbox-relations"; +import { PrinterConfigurationsPage } from "../printers/printer-configurations-page"; import { WorkProfilesPage } from "../profiles/work-profiles-page"; import { CompanyFeedback } from "./company-feedback"; @@ -116,8 +117,8 @@ type CompanyTab = | "assets" | "inventory" | "workstations" + | "printers" | "folders" - | "mailboxes" | "mail" | "profiles"; @@ -220,7 +221,7 @@ export function CompanyDetail({ ["sites", `${t("sites")} (${company.sites.length})`, MapPinIcon], ["access", accessTranslation("title"), KeyRoundIcon], ["folders", t("folders.title"), MailIcon], - ["mailboxes", t("mailbox.title"), MailIcon], + ["printers", t("printers.title"), LayersIcon], ["mail", t("mail.title"), MailIcon], ["assets", assetTranslation("companyTitle"), LayoutTemplateIcon], ["resource-catalog", resourceTranslation("companyTitle"), LayoutTemplateIcon], @@ -270,10 +271,10 @@ function CompanySection({ ); case "profiles": return ; + case "printers": + return ; case "folders": return ; - case "mailboxes": - return ; case "mail": return ; case "workstations": @@ -281,7 +282,17 @@ function CompanySection({ case "inventory": return ; case "resources": - return ; + return ( + + resource.values.integration?.kind === "mailbox" ? ( + + ) : null + } + /> + ); case "resource-catalog": return ; case "assets": diff --git a/modules/companies/src/web/components/configuration-quick-editor.tsx b/modules/companies/src/web/components/configuration-quick-editor.tsx index 5dbb9c1..a544a67 100644 --- a/modules/companies/src/web/components/configuration-quick-editor.tsx +++ b/modules/companies/src/web/components/configuration-quick-editor.tsx @@ -18,6 +18,7 @@ const titles = { } as const; export function ConfigurationQuickEditor({ kind, + title, company, csrfToken, values, @@ -26,6 +27,7 @@ export function ConfigurationQuickEditor({ onClose, }: { kind: ConfigurationQuickKind; + title?: string; company: CompanyDTO; csrfToken: string; values: Readonly<{ personId: string; accountId: string }>; @@ -65,7 +67,7 @@ export function ConfigurationQuickEditor({ } }} open - title={t(`mail.${titles[kind]}`)} + title={title ?? t(`mail.${titles[kind]}`)} wide > {kind === "person" && ( diff --git a/modules/companies/src/web/i18n.ts b/modules/companies/src/web/i18n.ts index 3fe7c69..c080b55 100644 --- a/modules/companies/src/web/i18n.ts +++ b/modules/companies/src/web/i18n.ts @@ -1,12 +1,14 @@ -import {foldersSpanish,foldersEnglish} from "./folders/folder-i18n"; +import { foldersEnglish, foldersSpanish } from "./folders/folder-i18n"; import { mailEnglish, mailSpanish } from "./mail/mail-i18n"; import { mailboxEnglish, mailboxSpanish } from "./mail/mailbox-i18n"; +import { printersEnglish, printersSpanish } from "./printers/printer-i18n"; import { profilesEnglish, profilesSpanish } from "./profiles/profile-i18n"; export const companiesSpanish = { + printers: printersSpanish, mail: mailSpanish, mailbox: mailboxSpanish, - folders:foldersSpanish, + folders: foldersSpanish, profiles: profilesSpanish, companySpaces: "Espacios de compañías", globalCatalogs: "Catálogos globales", @@ -122,9 +124,10 @@ export const companiesSpanish = { }, }; export const companiesEnglish = { + printers: printersEnglish, mail: mailEnglish, mailbox: mailboxEnglish, - folders:foldersEnglish, + folders: foldersEnglish, profiles: profilesEnglish, companySpaces: "Company spaces", globalCatalogs: "Global catalogs", diff --git a/modules/companies/src/web/mail/mail.test.tsx b/modules/companies/src/web/mail/mail.test.tsx index be8f8d7..91d3b97 100644 --- a/modules/companies/src/web/mail/mail.test.tsx +++ b/modules/companies/src/web/mail/mail.test.tsx @@ -2,6 +2,12 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { inventoryKeys } from "@metis/assets/client"; +import { + catalogKeys as resourceCatalogKeys, + inventoryKeys as resourceKeys, + resourcesEnglish, + resourcesSpanish, +} from "@metis/resources/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createInstance } from "i18next"; import type { ReactNode } from "react"; @@ -17,7 +23,7 @@ import { MailConfigurationForm } from "./mail-configuration-form"; import { MailConfigurationsPage } from "./mail-configurations-page"; import { mailboxKeys } from "./mailbox-api"; import { MailboxForm } from "./mailbox-form"; -import { MailboxesPage } from "./mailboxes-page"; +import { MailboxRelations } from "./mailbox-relations"; const company = { id: "0199b200-0000-7000-8000-000000000001", @@ -33,11 +39,47 @@ async function render(language: string, node: ReactNode) { const i18n = createInstance(); await i18n.init({ lng: language, - resources: { es: { companies: companiesSpanish }, en: { companies: companiesEnglish } }, + resources: { + es: { companies: companiesSpanish, resources: resourcesSpanish }, + en: { companies: companiesEnglish, resources: resourcesEnglish }, + }, }); const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY } }, }); + const addressId = "0199b200-0000-7000-8000-000000000002"; + const contextId = "0199b200-0000-7000-8000-000000000003"; + const templateId = "0199b200-0000-7000-8000-000000000004"; + client.setQueryData(resourceKeys.company(company.id), { resources: [], references: [] }); + client.setQueryData(resourceCatalogKeys.scope(company.id), { + items: [ + { + id: addressId, + builtinKey: "field:email", + effectiveActive: true, + values: { kind: "field", name: "Dirección de correo", type: "text", unit: "", choices: [] }, + }, + { + id: contextId, + builtinKey: "field:context", + effectiveActive: true, + values: { kind: "field", name: "Contexto", type: "text", unit: "", choices: [] }, + }, + { + id: templateId, + builtinKey: "template:mailbox", + effectiveActive: true, + values: { + kind: "template", + name: "Buzón de correo", + fields: [ + { fieldId: addressId, required: false }, + { fieldId: contextId, required: false }, + ], + }, + }, + ], + }); client.setQueryData(mailKeys.company(company.id), { configurations: [] }); client.setQueryData(profileKeys.company(company.id), { profiles: [] }); client.setQueryData(mailboxKeys.company(company.id), { mailboxes: [], uses: [] }); @@ -89,15 +131,22 @@ test("correo: selectores reutilizables con altas y ayuda inmediatamente antes de test("Buzones: alta en modal, cuenta opcional y ayuda al final", async () => { for (const language of ["es", "en"]) { - const page = await render(language, ); + const page = await render( + language, + + ); assert.equal(page.includes(" undefined} /> ); - assert.ok(form.includes("Cuenta informática relacionada (opcional)")); - assert.ok(form.indexOf("¿Qué es un buzón de correo?") < form.indexOf("Guardar")); + assert.ok(form.includes("Cuenta relacionada (opcional)")); + assert.ok(form.indexOf("¿Qué es y para qué sirve?") < form.indexOf("Guardar")); assert.equal(form.includes('type="password"'), false); }); diff --git a/modules/companies/src/web/mail/mailbox-form.tsx b/modules/companies/src/web/mail/mailbox-form.tsx index f51f9e4..410769d 100644 --- a/modules/companies/src/web/mail/mailbox-form.tsx +++ b/modules/companies/src/web/mail/mailbox-form.tsx @@ -1,26 +1,8 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { Button } from "@repo/shadcn-ui/components/button"; -import { ContextHelp } from "@repo/shadcn-ui/components/context-help"; -import { FormActions } from "@repo/shadcn-ui/components/form-dialog"; -import { Input } from "@repo/shadcn-ui/components/input"; -import { Label } from "@repo/shadcn-ui/components/label"; -import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action"; -import { Textarea } from "@repo/shadcn-ui/components/textarea"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useId, useRef, useState } from "react"; -import { useForm } from "react-hook-form"; -import { useTranslation } from "react-i18next"; +import { ResourceEditor } from "@metis/resources/client"; import type { CompanyDTO } from "../../common"; -import { type MailboxDTO, type MailboxInput, MailboxInputSchema } from "../../common/mailboxes"; -import { accessApi, accessKeys } from "../access/access-api"; -import { accessLabel } from "../access/access-labels"; -import { ConfigurationQuickEditor } from "../components/configuration-quick-editor"; - -import { mailKeys } from "./mail-api"; -import { MailFeedback } from "./mail-feedback"; -import { mailboxApi, mailboxKeys } from "./mailbox-api"; - +import type { MailboxDTO } from "../../common/mailboxes"; +/** El alta rápida de correo utiliza la misma ficha y persistencia que Recursos. */ export function MailboxForm({ company, csrfToken, @@ -36,158 +18,15 @@ export function MailboxForm({ allowQuickCreate?: boolean; initialAddress?: string; }) { - const { t } = useTranslation("companies"); - const prefix = useId(); - const client = useQueryClient(); - const mounted = useRef(true); - useEffect(() => { - mounted.current = true; - return () => { - mounted.current = false; - }; - }, []); - const [quick, setQuick] = useState(false); - const access = useQuery({ - queryKey: accessKeys.scope(company.id), - queryFn: () => accessApi.request(company.id), - }); - const form = useForm({ - resolver: zodResolver(MailboxInputSchema), - defaultValues: mailbox?.values ?? { - address: initialAddress, - kind: "corporate", - context: null, - accountId: null, - active: true, - notes: null, - }, - }); - const values = form.watch(); - const mutation = useMutation({ - mutationFn: (input: MailboxInput) => - mailboxApi.request(company.id, input, csrfToken, mailbox?.id), - onSuccess: async (result) => { - await Promise.all([ - client.invalidateQueries({ queryKey: mailboxKeys.company(company.id) }), - client.invalidateQueries({ queryKey: mailKeys.company(company.id) }), - ]); - if (mounted.current && result.savedId) { - onSaved(result.savedId); - } - }, - }); return ( - <> -
mutation.mutate(input))}> -
-
- - -
-
- - { - const parsed = MailboxInputSchema.shape.kind.safeParse(value); - if (parsed.success) { - form.setValue("kind", parsed.data); - } - }} - options={MailboxInputSchema.shape.kind.options.map((kind) => ({ - value: kind, - label: t(`mailbox.${kind}`), - }))} - placeholder={t("mail.choose")} - value={values.kind} - /> -
-
- - form.setValue("context", event.target.value || null)} - value={values.context ?? ""} - /> -
-
- - setQuick(true) } - : undefined - } - disabled={!access.data} - id={`${prefix}-account`} - label={t("mailbox.account")} - onValueChange={(id) => form.setValue("accountId", id || null)} - options={(access.data?.items ?? []) - .filter((item) => item.values.kind === "account") - .map((item) => ({ - value: item.id, - label: accessLabel(item, access.data?.items ?? [], company.people), - }))} - placeholder={t("mailbox.noAccount")} - value={values.accountId ?? ""} - /> -
- -
- -