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("
)}
-
+
);
}
+
+function resourceFields(
+ resource: ResourceDTO | undefined,
+ template: CatalogItemDTO | undefined,
+ items: readonly CatalogItemDTO[]
+) {
+ return (
+ resource?.specification.fields ??
+ (template?.values.kind === "template"
+ ? template.values.fields.flatMap((binding) => {
+ const field = items.find((item) => item.id === binding.fieldId);
+ return field?.values.kind === "field"
+ ? [
+ {
+ fieldId: field.id,
+ name: field.values.name,
+ type: field.values.type,
+ unit: field.values.unit,
+ choices: field.values.choices,
+ required: binding.required,
+ },
+ ]
+ : [];
+ })
+ : [])
+ );
+}
+
+function _notesLimit(values: ResourceInput, template: CatalogItemDTO | undefined) {
+ return values.integration || template?.builtinKey === "template:mailbox" ? 4000 : 10000;
+}
+function requireMailboxAddress(
+ field: ReturnType[number],
+ items: readonly CatalogItemDTO[],
+ values: ResourceInput,
+ template: CatalogItemDTO | undefined
+) {
+ return {
+ ...field,
+ required:
+ field.required ||
+ (field.fieldId === mailboxIntegration(items, values.integration)?.addressFieldId &&
+ template?.builtinKey === "template:mailbox"),
+ };
+}
+
+function resourceDefaults(
+ items: readonly CatalogItemDTO[],
+ resource: ResourceDTO | undefined,
+ initialKind: "mailbox" | "access" | undefined,
+ initialAddress: string,
+ initialIntegration: ResourceInput["integration"]
+): ResourceInput {
+ if (resource) {
+ return resource.values;
+ }
+ const template = initialKind
+ ? items.find((item) => item.builtinKey === `template:${initialKind}`)
+ : undefined;
+ const addressField = items.find((item) => item.builtinKey === "field:email");
+ return {
+ name: initialAddress.slice(0, 100),
+ templateId: template?.id ?? "",
+ active: true,
+ integration: initialIntegration,
+ notes: "",
+ characteristics: addressField && initialAddress ? { [addressField.id]: initialAddress } : {},
+ };
+}
diff --git a/modules/resources/src/web/resource-integration-fields.tsx b/modules/resources/src/web/resource-integration-fields.tsx
new file mode 100644
index 0000000..13bcaff
--- /dev/null
+++ b/modules/resources/src/web/resource-integration-fields.tsx
@@ -0,0 +1,165 @@
+import { Label } from "@repo/shadcn-ui/components/label";
+import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
+import { useId } from "react";
+import { useTranslation } from "react-i18next";
+
+import type { CatalogItemDTO, ResourceInput, ResourceReferenceDTO } from "../common";
+
+export function mailboxIntegration(
+ items: readonly CatalogItemDTO[],
+ previous?: ResourceInput["integration"]
+) {
+ if (previous?.kind === "mailbox") {
+ return previous;
+ }
+ const addressFieldId = items.find((item) => item.builtinKey === "field:email")?.id;
+ const contextFieldId = items.find((item) => item.builtinKey === "field:context")?.id;
+ return addressFieldId && contextFieldId
+ ? {
+ kind: "mailbox" as const,
+ addressFieldId,
+ contextFieldId,
+ mailboxKind: "corporate" as const,
+ accountId: null,
+ }
+ : undefined;
+}
+export function ResourceIntegrationFields({
+ templateKey,
+ integration,
+ items,
+ references,
+ fixed,
+ onChange,
+}: {
+ templateKey: string | null | undefined;
+ integration: ResourceInput["integration"];
+ items: readonly CatalogItemDTO[];
+ references: readonly ResourceReferenceDTO[];
+ fixed: boolean;
+ onChange: (value: ResourceInput["integration"]) => void;
+}) {
+ const { t } = useTranslation("resources");
+ const prefix = useId();
+ const mailbox =
+ integration?.kind === "mailbox"
+ ? integration
+ : templateKey === "template:mailbox"
+ ? mailboxIntegration(items)
+ : undefined;
+ if (mailbox) {
+ return (
+
+
+
+ {
+ if (value === "corporate" || value === "external") {
+ onChange({ ...mailbox, mailboxKind: value });
+ }
+ }}
+ options={["corporate", "external"].map((value) => ({ value, label: t(value) }))}
+ placeholder={t("chooseValue")}
+ value={mailbox.mailboxKind}
+ />
+
+
+
+
+ onChange({ ...mailbox, accountId: value === "none" ? null : value })
+ }
+ options={[
+ { value: "none", label: t("noValue") },
+ ...references
+ .filter(
+ (item) =>
+ item.kind === "account" && (item.active || item.id === mailbox.accountId)
+ )
+ .map((item) => ({ value: item.id, label: item.name })),
+ ]}
+ placeholder={t("chooseValue")}
+ value={mailbox.accountId ?? "none"}
+ />
+
+
+ );
+ }
+ if (templateKey !== "template:access" && integration?.kind !== "access") {
+ return null;
+ }
+ const referenceFieldId =
+ integration?.kind === "access"
+ ? integration.referenceFieldId
+ : items.find((item) => item.builtinKey === "field:reference")?.id;
+ if (!referenceFieldId) {
+ return null;
+ }
+ const access = integration?.kind === "access" ? integration : undefined;
+ const change = (key: "resourceTypeId" | "systemId" | "environmentId", value: string) => {
+ const resourceTypeId = key === "resourceTypeId" ? value : access?.resourceTypeId;
+ if (!resourceTypeId) {
+ return;
+ }
+ const systemId = key === "systemId" ? optionalReference(value) : (access?.systemId ?? null);
+ let environmentId = access?.environmentId ?? null;
+ if (key === "systemId") {
+ environmentId = null;
+ } else if (key === "environmentId") {
+ environmentId = optionalReference(value);
+ }
+ onChange({ kind: "access", referenceFieldId, resourceTypeId, systemId, environmentId });
+ };
+ return (
+
+ {(["resourceTypeId", "systemId", "environmentId"] as const).map((key) => {
+ const kind =
+ key === "resourceTypeId"
+ ? "resource_type"
+ : key === "systemId"
+ ? "system"
+ : "environment";
+ const selected = access?.[key] ?? "none";
+ const options = references
+ .filter(
+ (item) =>
+ item.kind === kind &&
+ (item.active || item.id === selected) &&
+ (kind !== "environment" || item.systemId === access?.systemId)
+ )
+ .map((item) => ({ value: item.id, label: item.name }));
+ return (
+
+
+ {
+ if (key !== "resourceTypeId" || value !== "none") {
+ change(key, value);
+ }
+ }}
+ options={[{ value: "none", label: t("noValue") }, ...options]}
+ placeholder={t("chooseValue")}
+ value={selected}
+ />
+
+ );
+ })}
+
+ );
+}
+
+function optionalReference(value: string) {
+ return value === "none" ? null : value;
+}
diff --git a/modules/resources/src/web/resources-page.tsx b/modules/resources/src/web/resources-page.tsx
index f2ef511..71e6631 100644
--- a/modules/resources/src/web/resources-page.tsx
+++ b/modules/resources/src/web/resources-page.tsx
@@ -4,15 +4,26 @@ import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { Input } from "@repo/shadcn-ui/components/input";
import { Label } from "@repo/shadcn-ui/components/label";
import { useQuery } from "@tanstack/react-query";
+import type { ReactNode } from "react";
import { useId, useState } from "react";
import { useTranslation } from "react-i18next";
+import type { ResourceDTO } from "../common";
+
import { catalogApi, catalogKeys } from "./catalog-api";
import { inventoryApi, inventoryKeys } from "./inventory-api";
import { ResourceDetail } from "./resource-detail";
import { InventoryFeedback, ResourceForm } from "./resource-form";
-export function ResourcesPage({ companyId, csrfToken }: { companyId: string; csrfToken: string }) {
+export function ResourcesPage({
+ companyId,
+ csrfToken,
+ relatedContent,
+}: {
+ companyId: string;
+ csrfToken: string;
+ relatedContent?: (resource: ResourceDTO) => ReactNode;
+}) {
const { t } = useTranslation("resources");
const searchId = useId();
const [search, setSearch] = useState("");
@@ -132,7 +143,8 @@ export function ResourcesPage({ companyId, csrfToken }: { companyId: string; csr
title={viewed.values.name}
wide
>
-
+
+ {relatedContent?.(viewed)}
)}