Impresoras en perfil de usuario

This commit is contained in:
David Arranz 2026-10-05 17:01:32 +02:00
parent 03c1c30146
commit f957dd40cf
73 changed files with 3761 additions and 539 deletions

View File

@ -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<void>((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 }

View File

@ -10,16 +10,22 @@ import type { readDatabaseEnvironment } from "../../config/database";
export async function createIdentityRuntime(
database: Sequelize,
environment: ReturnType<typeof readDatabaseEnvironment>
environment: ReturnType<typeof readDatabaseEnvironment>,
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,

View File

@ -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<Response>;
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
);
}

View File

@ -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<Response>;
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<typeof MailboxesResponseSchema>,
originalAccess: z.infer<typeof AccessResponseSchema>
) {
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 ?? "");
}
}

View File

@ -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 }),

View File

@ -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.

View File

@ -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).

View File

@ -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).

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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:*",

View File

@ -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;
}

View File

@ -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)
);
}

View File

@ -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(),
})),
};
}

View File

@ -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<string, string>;
}>;
export interface IPrinterConfigurationRepository {
get(company: UniqueID): Promise<Result<PrinterContext, PrinterConfigurationError>>;
save(
company: UniqueID,
action: (context: PrinterContext) => Result<PrinterConfiguration, PrinterConfigurationError>
): Promise<
Result<
{ configuration: PrinterConfiguration; context: PrinterContext },
PrinterConfigurationError
>
>;
}

View File

@ -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;
});
}
}

View File

@ -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<PrinterConfigurationRevisionProps> {
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;
}
}

View File

@ -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<typeof PrinterConfigurationInputSchema>;
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;

View File

@ -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<Props> {
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<PrinterConfiguration, PrinterConfigurationError> {
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<PrinterConfiguration, PrinterConfigurationError> {
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
)
);
}
}

View File

@ -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() }))
)
);
});

View File

@ -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<string>
) {
Object.freeze(this);
}
static create(
input: PrinterConfigurationInput
): Result<PrinterConfigurationValues, PrinterConfigurationError> {
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,
};
}
}

View File

@ -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

View File

@ -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

View File

@ -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);
}

View File

@ -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",
});
}
}

View File

@ -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,
};
}

View File

@ -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<Result<PrinterContext, PrinterConfigurationError>> {
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<PrinterRow>(
"SELECT * FROM printer_configurations WHERE companyId=:companyId",
{ ...options, type: QueryTypes.SELECT }
);
const revisions = await this.database.query<PrinterRevisionRow>(
"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<ProfileRow>(
"SELECT * FROM work_profiles WHERE companyId=:companyId",
{ ...options, type: QueryTypes.SELECT }
);
const profileRevisions = await this.database.query<ProfileRevisionRow>(
"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<IPrinterConfigurationRepository["save"]>[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 });
});
}
}

View File

@ -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<typeof registerWorkProfileRoutes>[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<typeof registerMailRoutes>[0]) => {
registerMailRoutes(app, mail, authenticate);
registerMailboxRoutes(app, mailboxes, authenticate);
registerMailboxRoutes(app, mailboxes, authenticate, legacyResourceWrites);
},
};
},
register: (app: Parameters<typeof registerCompanyRoutes>[0]) => {
registerCompanyRoutes(app, useCases, authenticate);
registerAccessRoutes(app, access, authenticate);
registerAccessRoutes(app, access, authenticate, legacyResourceWrites);
},
};
}

View File

@ -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";

View File

@ -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<typeof PrinterConfigurationSchema>;

View File

@ -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({
</FormDialog>
);
}
export function AccessForm(props: Parameters<typeof AccessFormInternal>[0]) {
if (props.kind === "resource") {
const type = props.initialValues?.resourceTypeId;
return (
<ResourceEditor
allowQuickCreate={props.allowQuickCreate ?? true}
companyId={props.scope}
csrfToken={props.csrfToken}
initialAccess={
typeof type === "string" && type
? { resourceTypeId: type, systemId: null, environmentId: null }
: undefined
}
initialKind="access"
onSaved={(id) => props.onSaved(id)}
resourceId={props.item?.id}
/>
);
}
return <AccessFormInternal {...props} />;
}

View File

@ -55,7 +55,6 @@ export function AccessWorkspacePage({
"account_person",
"group",
"membership",
"resource",
"grant",
"need",
"coverage",

View File

@ -262,14 +262,7 @@ test("los formularios ofrecen altas relacionadas y el segundo modal evita altas
</QueryClientProvider>
</I18nextProvider>
);
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);
}

View File

@ -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 <WorkProfilesPage company={company} csrfToken={csrfToken} />;
case "printers":
return <PrinterConfigurationsPage company={company} csrfToken={csrfToken} />;
case "folders":
return <FolderMappingsPage company={company} csrfToken={csrfToken} />;
case "mailboxes":
return <MailboxesPage company={company} csrfToken={csrfToken} />;
case "mail":
return <MailConfigurationsPage company={company} csrfToken={csrfToken} />;
case "workstations":
@ -281,7 +282,17 @@ function CompanySection({
case "inventory":
return <CompanyInventory company={company} csrfToken={csrfToken} />;
case "resources":
return <ResourcesPage companyId={company.id} csrfToken={csrfToken} />;
return (
<ResourcesPage
companyId={company.id}
csrfToken={csrfToken}
relatedContent={(resource) =>
resource.values.integration?.kind === "mailbox" ? (
<MailboxRelations company={company} csrfToken={csrfToken} mailboxId={resource.id} />
) : null
}
/>
);
case "resource-catalog":
return <ResourceCatalogPage csrfToken={csrfToken} scope={company.id} />;
case "assets":

View File

@ -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" && (

View File

@ -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",

View File

@ -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, <MailboxesPage company={company} csrfToken="test" />);
const page = await render(
language,
<MailboxRelations
company={company}
csrfToken="test"
mailboxId="0199b200-0000-7000-8000-000000000005"
/>
);
assert.equal(page.includes("<form"), false);
assert.ok(page.includes(language === "es" ? "Nuevo buzón" : "New mailbox"));
assert.ok(page.includes(language === "es" ? "Vincular persona" : "Link person"));
}
const form = await render(
"es",
<MailboxForm company={company} csrfToken="test" onSaved={() => undefined} />
);
assert.ok(form.includes("Cuenta informática relacionada (opcional)"));
assert.ok(form.indexOf("¿Qué es un buzón de correo?") < form.indexOf("Guardar"));
assert.ok(form.includes("Cuenta relacionada (opcional)"));
assert.ok(form.indexOf("¿Qué es y para qué sirve?") < form.indexOf("Guardar"));
assert.equal(form.includes('type="password"'), false);
});

View File

@ -1,26 +1,8 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@repo/shadcn-ui/components/button";
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
import { Input } from "@repo/shadcn-ui/components/input";
import { Label } from "@repo/shadcn-ui/components/label";
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
import { Textarea } from "@repo/shadcn-ui/components/textarea";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useId, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { ResourceEditor } from "@metis/resources/client";
import type { CompanyDTO } from "../../common";
import { type MailboxDTO, type MailboxInput, MailboxInputSchema } from "../../common/mailboxes";
import { accessApi, accessKeys } from "../access/access-api";
import { accessLabel } from "../access/access-labels";
import { ConfigurationQuickEditor } from "../components/configuration-quick-editor";
import { mailKeys } from "./mail-api";
import { MailFeedback } from "./mail-feedback";
import { mailboxApi, mailboxKeys } from "./mailbox-api";
import type { MailboxDTO } from "../../common/mailboxes";
/** El alta rápida de correo utiliza la misma ficha y persistencia que Recursos. */
export function MailboxForm({
company,
csrfToken,
@ -36,158 +18,15 @@ export function MailboxForm({
allowQuickCreate?: boolean;
initialAddress?: string;
}) {
const { t } = useTranslation("companies");
const prefix = useId();
const client = useQueryClient();
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const [quick, setQuick] = useState(false);
const access = useQuery({
queryKey: accessKeys.scope(company.id),
queryFn: () => accessApi.request(company.id),
});
const form = useForm<MailboxInput>({
resolver: zodResolver(MailboxInputSchema),
defaultValues: mailbox?.values ?? {
address: initialAddress,
kind: "corporate",
context: null,
accountId: null,
active: true,
notes: null,
},
});
const values = form.watch();
const mutation = useMutation({
mutationFn: (input: MailboxInput) =>
mailboxApi.request(company.id, input, csrfToken, mailbox?.id),
onSuccess: async (result) => {
await Promise.all([
client.invalidateQueries({ queryKey: mailboxKeys.company(company.id) }),
client.invalidateQueries({ queryKey: mailKeys.company(company.id) }),
]);
if (mounted.current && result.savedId) {
onSaved(result.savedId);
}
},
});
return (
<>
<form className="space-y-5" onSubmit={form.handleSubmit((input) => mutation.mutate(input))}>
<fieldset className="space-y-4" disabled={mutation.isPending}>
<div className="space-y-2">
<Label htmlFor={prefix}>{t("mail.address")}</Label>
<Input
id={prefix}
maxLength={254}
required
type="email"
{...form.register("address")}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-kind`}>{t("mailbox.kind")}</Label>
<SelectWithAction
disabled={!!mailbox}
id={`${prefix}-kind`}
label={t("mailbox.kind")}
onValueChange={(value) => {
const parsed = MailboxInputSchema.shape.kind.safeParse(value);
if (parsed.success) {
form.setValue("kind", parsed.data);
}
}}
options={MailboxInputSchema.shape.kind.options.map((kind) => ({
value: kind,
label: t(`mailbox.${kind}`),
}))}
placeholder={t("mail.choose")}
value={values.kind}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-context`}>{t("mailbox.context")}</Label>
<Input
id={`${prefix}-context`}
maxLength={255}
onChange={(event) => form.setValue("context", event.target.value || null)}
value={values.context ?? ""}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-account`}>{t("mailbox.account")}</Label>
<SelectWithAction
action={
allowQuickCreate
? { label: t("mail.newAccount"), onClick: () => setQuick(true) }
: undefined
}
disabled={!access.data}
id={`${prefix}-account`}
label={t("mailbox.account")}
onValueChange={(id) => form.setValue("accountId", id || null)}
options={(access.data?.items ?? [])
.filter((item) => item.values.kind === "account")
.map((item) => ({
value: item.id,
label: accessLabel(item, access.data?.items ?? [], company.people),
}))}
placeholder={t("mailbox.noAccount")}
value={values.accountId ?? ""}
/>
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" {...form.register("active")} />
{t("mailbox.active")}
</label>
<div className="space-y-2">
<Label htmlFor={`${prefix}-notes`}>{t("mail.notes")}</Label>
<Textarea
id={`${prefix}-notes`}
maxLength={4000}
onChange={(event) => form.setValue("notes", event.target.value || null)}
value={values.notes ?? ""}
/>
</div>
</fieldset>
{Object.keys(form.formState.errors).length > 0 && (
<p role="alert">{t("mail.errors.INVALID_REQUEST")}</p>
)}
<MailFeedback error={mutation.error ?? access.error} />
<ContextHelp
description={t("mailbox.description")}
examples={t("mailbox.examples")}
examplesLabel={t("mail.examplesLabel")}
guidance={t("mailbox.guidance")}
title={t("mailbox.help")}
/>
<FormActions>
<Button disabled={mutation.isPending || !access.data} type="submit">
{t(mutation.isPending ? "saving" : "save")}
</Button>
</FormActions>
</form>
{quick && (
<ConfigurationQuickEditor
company={company}
csrfToken={csrfToken}
data={access.data}
kind="account"
onClose={() => setQuick(false)}
onCreated={(_field, id) => {
if (mounted.current && id) {
form.setValue("accountId", id);
setQuick(false);
}
}}
values={{ personId: "", accountId: "" }}
/>
)}
</>
<ResourceEditor
allowQuickCreate={allowQuickCreate}
companyId={company.id}
csrfToken={csrfToken}
initialAddress={initialAddress}
initialKind="mailbox"
onSaved={onSaved}
resourceId={mailbox?.id}
/>
);
}

View File

@ -0,0 +1,78 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { CompanyDTO } from "../../common";
import { MailFeedback } from "./mail-feedback";
import { mailboxApi, mailboxKeys } from "./mailbox-api";
import { MailboxUseForm } from "./mailbox-use-form";
export function MailboxRelations({
company,
csrfToken,
mailboxId,
}: {
company: CompanyDTO;
csrfToken: string;
mailboxId: string;
}) {
const { t } = useTranslation("companies");
const [editor, setEditor] = useState<string>();
const query = useQuery({
queryKey: mailboxKeys.company(company.id),
queryFn: () => mailboxApi.request(company.id),
});
const uses = query.data?.uses.filter((item) => item.values.mailboxId === mailboxId) ?? [];
const edited = uses.find((item) => item.id === editor);
return (
<section className="space-y-3 border-t pt-4">
<div className="flex items-center justify-between gap-3">
<h3 className="font-medium">{t("people")}</h3>
<Button onClick={() => setEditor("new")} size="sm" variant="outline">
{t("mailbox.linkPerson")}
</Button>
</div>
<MailFeedback error={query.error} />
<ul className="space-y-2">
{uses.map((item) => (
<li className="flex items-center justify-between gap-3 text-sm" key={item.id}>
<span>
{company.people.find((person) => person.id === item.values.personId)?.name ??
t("people")}{" "}
· {item.values.startDate} → {item.values.endDate ?? t("mailbox.openPeriod")}
</span>
{!item.values.endDate && (
<Button onClick={() => setEditor(item.id)} size="sm" variant="outline">
{t("mailbox.closeUse")}
</Button>
)}
</li>
))}
</ul>
{editor && (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
setEditor(undefined);
}
}}
open
title={t(edited ? "mailbox.closeUse" : "mailbox.linkPerson")}
>
<MailboxUseForm
allowQuickCreate={false}
company={company}
csrfToken={csrfToken}
initialMailboxId={mailboxId}
onSaved={() => setEditor(undefined)}
use={edited}
/>
</FormDialog>
)}
</section>
);
}

View File

@ -1,207 +0,0 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { Input } from "@repo/shadcn-ui/components/input";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { CompanyDTO } from "../../common";
import { MailFeedback } from "./mail-feedback";
import { mailboxApi, mailboxKeys } from "./mailbox-api";
import { MailboxForm } from "./mailbox-form";
import { MailboxUseForm } from "./mailbox-use-form";
export function MailboxesPage({ company, csrfToken }: { company: CompanyDTO; csrfToken: string }) {
const { t, i18n } = useTranslation("companies");
const [editor, setEditor] = useState<string>();
const [useEditor, setUseEditor] = useState<string>();
const [view, setView] = useState<string>();
const [search, setSearch] = useState("");
const query = useQuery({
queryKey: mailboxKeys.company(company.id),
queryFn: () => mailboxApi.request(company.id),
});
const edited = query.data?.mailboxes.find((item) => item.id === editor);
const use = query.data?.uses.find((item) => item.id === useEditor);
const viewed = query.data?.mailboxes.find((item) => item.id === view);
const name = (id: string) => company.people.find((person) => person.id === id)?.name ?? id;
const boxes = query.data?.mailboxes ?? [];
const filtered = boxes.filter((item) =>
[item.values.address, item.values.context ?? ""].some((value) =>
value.toLowerCase().includes(search.trim().toLowerCase())
)
);
return (
<div className="space-y-5">
<header className="flex flex-wrap justify-between items-center gap-4">
<h2 className="text-xl font-semibold">{t("mailbox.title")}</h2>
<div className="flex gap-2">
<Button disabled={!query.data} onClick={() => setUseEditor("new")} variant="outline">
{t("mailbox.linkPerson")}
</Button>
<Button disabled={!query.data} onClick={() => setEditor("new")}>
{t("mailbox.new")}
</Button>
</div>
</header>
<Input
aria-label={t("mailbox.search")}
onChange={(event) => setSearch(event.target.value)}
placeholder={t("mailbox.search")}
value={search}
/>
{query.isPending && <p role="status">{t("loading")}</p>}
<MailFeedback error={query.error} />
{query.isError && (
<Button
onClick={() => {
query.refetch().catch(() => undefined);
}}
variant="outline"
>
{t("retry")}
</Button>
)}
{query.data && (
<Card>
<CardContent className="divide-y pt-3">
{!filtered.length && (
<p className="py-4 text-muted-foreground">
{t(boxes.length ? "noResults" : "mailbox.empty")}
</p>
)}
{filtered.map((item) => (
<div className="py-4 space-y-3" key={item.id}>
<div className="flex flex-wrap justify-between gap-3">
<div>
<h3 className="font-medium">{item.values.address}</h3>
<p className="text-sm text-muted-foreground">
{t(`mailbox.${item.values.kind}`)} ·{" "}
{t(item.values.active ? "active" : "inactive")}
{item.values.context ? ` · ${item.values.context}` : ""}
</p>
</div>
<div className="flex gap-2">
<Button onClick={() => setView(item.id)} size="sm" variant="outline">
{t("mail.history")}
</Button>
<Button onClick={() => setEditor(item.id)} size="sm" variant="outline">
{t("edit")}
</Button>
</div>
</div>
<ul className="space-y-2">
{query.data?.uses
.filter((use) => use.values.mailboxId === item.id)
.map((use) => (
<li className="flex justify-between gap-3 text-sm" key={use.id}>
<span>
{name(use.values.personId)} · {use.values.startDate} →{" "}
{use.values.endDate ?? t("mailbox.openPeriod")}
</span>
{!use.values.endDate && (
<Button onClick={() => setUseEditor(use.id)} size="sm" variant="outline">
{t("mailbox.closeUse")}
</Button>
)}
</li>
))}
</ul>
</div>
))}
</CardContent>
</Card>
)}
{editor && (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
setEditor(undefined);
}
}}
open
title={t(edited ? "mailbox.edit" : "mailbox.new")}
wide
>
<MailboxForm
company={company}
csrfToken={csrfToken}
mailbox={edited}
onSaved={() => setEditor(undefined)}
/>
</FormDialog>
)}
{useEditor && (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
setUseEditor(undefined);
}
}}
open
title={t(use ? "mailbox.closeUse" : "mailbox.linkPerson")}
wide
>
<MailboxUseForm
company={company}
csrfToken={csrfToken}
onSaved={() => setUseEditor(undefined)}
use={use}
/>
</FormDialog>
)}
{viewed && (
<FormDialog
cancelLabel={t("close")}
onOpenChange={(open) => {
if (!open) {
setView(undefined);
}
}}
open
title={t("mail.history")}
wide
>
<ol className="space-y-3">
{[...viewed.history].reverse().map((revision) => (
<li className="border rounded-md p-3 text-sm" key={revision.id}>
<p>
{new Intl.DateTimeFormat(i18n.language, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(revision.recordedAt))}{" "}
· {revision.actorName}
</p>
<p>
{revision.values.address} · {t(`mailbox.${revision.values.kind}`)} ·{" "}
{t(revision.values.active ? "active" : "inactive")}
</p>
<p>{revision.values.context}</p>
<p>{revision.values.notes}</p>
</li>
))}
</ol>
{query.data?.uses
.filter((use) => use.values.mailboxId === viewed.id)
.map((use) => (
<div className="mt-4 rounded-md border p-3 text-sm" key={use.id}>
<h3 className="font-medium">{name(use.values.personId)}</h3>
{use.history.map((revision) => (
<p key={revision.id}>
{revision.values.startDate} →{" "}
{revision.values.endDate ?? t("mailbox.openPeriod")} · {revision.actorName} ·{" "}
{revision.recordedAt}
</p>
))}
</div>
))}
</FormDialog>
)}
</div>
);
}

View File

@ -0,0 +1,43 @@
import { z } from "zod/v4";
import {
type PrinterConfigurationInput,
PrinterConfigurationResponseSchema,
PrinterConfigurationsResponseSchema,
} from "../../common/printer-configurations";
export const printerKeys = { company: (id: string) => ["printer-configurations", id] };
export class PrinterApiError extends Error {
constructor(public readonly code: string) {
super(code);
}
}
async function request(
companyId: string,
values?: PrinterConfigurationInput,
token?: string,
id?: string
) {
const response = await fetch(
`/api/printer-configurations/${companyId}${id ? `/${id}/update` : ""}`,
{
method: values ? "POST" : "GET",
credentials: "same-origin",
headers: values ? { "content-type": "application/json", "x-csrf-token": token ?? "" } : {},
...(values ? { body: JSON.stringify({ values }) } : {}),
}
);
const body: unknown = await response.json();
if (!response.ok) {
const error = z.object({ code: z.string() }).safeParse(body);
throw new PrinterApiError(error.success ? error.data.code : "INTERNAL_ERROR");
}
return body;
}
export const printerApi = {
async get(companyId: string) {
return PrinterConfigurationsResponseSchema.parse(await request(companyId));
},
async save(companyId: string, values: PrinterConfigurationInput, token: string, id?: string) {
return PrinterConfigurationResponseSchema.parse(await request(companyId, values, token, id));
},
};

View File

@ -0,0 +1,293 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
import type { AssetDTO } from "@metis/assets/common";
import { Button } from "@repo/shadcn-ui/components/button";
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
import { FormActions, FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { Label } from "@repo/shadcn-ui/components/label";
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
import { Textarea } from "@repo/shadcn-ui/components/textarea";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useId, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { CompanyDTO, WorkProfileDTO } from "../../common";
import {
PRINTER_STATE_TRANSITIONS,
type PrinterConfigurationDTO,
type PrinterConfigurationInput,
PrinterConfigurationInputSchema,
} from "../../common/printer-configurations";
import { ConfigurationQuickEditor } from "../components/configuration-quick-editor";
import { profileApi, profileKeys } from "../profiles/work-profile-api";
import { WorkProfileForm } from "../profiles/work-profile-form";
import { printerApi, printerKeys } from "./printer-api";
import { PrinterFeedback } from "./printer-feedback";
export function PrinterConfigurationForm({
company,
csrfToken,
configuration,
initialProfileId = "",
onSaved,
}: {
company: CompanyDTO;
csrfToken: string;
configuration?: PrinterConfigurationDTO | undefined;
initialProfileId?: string;
onSaved: () => void;
}) {
const { t } = useTranslation("companies");
const client = useQueryClient();
const prefix = useId();
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const [quick, setQuick] = useState<"profile" | "printer">();
const profiles = useQuery({
queryKey: profileKeys.company(company.id),
queryFn: () => profileApi.get(company.id),
});
const inventory = useQuery({
queryKey: inventoryKeys.company(company.id),
queryFn: () => inventoryApi.get(company.id),
});
const form = useForm<PrinterConfigurationInput>({
resolver: zodResolver(PrinterConfigurationInputSchema),
defaultValues: initialPrinterValues(configuration, initialProfileId),
});
const values = form.watch();
const profile = profiles.data?.profiles.find((item) => item.id === values.profileId);
const states = availablePrinterStates(configuration);
const profileFixed = Boolean(configuration || initialProfileId);
const ready = [profiles.data, inventory.data].every(Boolean);
const mutation = useMutation({
mutationFn: (input: PrinterConfigurationInput) =>
printerApi.save(company.id, input, csrfToken, configuration?.id),
onSuccess: async () => {
await client.invalidateQueries({ queryKey: printerKeys.company(company.id) });
if (mounted.current) {
onSaved();
}
},
});
return (
<>
<form className="space-y-5" onSubmit={form.handleSubmit((input) => mutation.mutate(input))}>
<fieldset className="space-y-4" disabled={mutation.isPending}>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`${prefix}-profile`}>{t("mail.profile")}</Label>
<SelectWithAction
action={
profileFixed
? undefined
: { label: t("profiles.new"), onClick: () => setQuick("profile") }
}
disabled={profileFixed || !ready}
id={`${prefix}-profile`}
label={t("mail.profile")}
onValueChange={(id) => {
form.setValue("profileId", id);
form.setValue("printerId", "");
}}
options={profileOptions(profiles.data?.profiles ?? [], values.profileId)}
placeholder={t("mail.choose")}
required
value={values.profileId}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-printer`}>{t("printers.printer")}</Label>
<SelectWithAction
action={
configuration
? undefined
: { label: t("printers.newAsset"), onClick: () => setQuick("printer") }
}
disabled={!!configuration || !ready || !profile}
id={`${prefix}-printer`}
label={t("printers.printer")}
onValueChange={(id) => form.setValue("printerId", id)}
options={printerOptions(
inventory.data?.assets ?? [],
profile?.values.assetId,
values.printerId
)}
placeholder={t("mail.choose")}
required
value={values.printerId}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-connection`}>{t("printers.connection")}</Label>
<SelectWithAction
id={`${prefix}-connection`}
label={t("printers.connection")}
onValueChange={(value) => {
const parsed = PrinterConfigurationInputSchema.shape.connection.safeParse(value);
if (parsed.success) {
form.setValue("connection", parsed.data);
}
}}
options={["usb", "network"].map((value) => ({
value,
label: t(`printers.${value}`),
}))}
placeholder={t("mail.choose")}
required
value={values.connection}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-state`}>{t("mail.state")}</Label>
<SelectWithAction
id={`${prefix}-state`}
label={t("mail.state")}
onValueChange={(value) => {
const parsed = PrinterConfigurationInputSchema.shape.state.safeParse(value);
if (parsed.success) {
form.setValue("state", parsed.data);
if (!["pending", "configured"].includes(parsed.data)) {
form.setValue("isDefault", false);
}
}
}}
options={states.map((value) => ({ value, label: t(`mail.${value}`) }))}
placeholder={t("mail.choose")}
required
value={values.state}
/>
</div>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
{...form.register("isDefault")}
disabled={!["pending", "configured"].includes(values.state)}
/>
{t("printers.default")}
</label>
<div className="space-y-2">
<Label htmlFor={`${prefix}-notes`}>{t("mail.notes")}</Label>
<Textarea
id={`${prefix}-notes`}
maxLength={4000}
onChange={(event) => form.setValue("notes", event.target.value || null)}
rows={3}
value={values.notes ?? ""}
/>
</div>
</fieldset>
{configuration && <p className="text-sm text-muted-foreground">{t("printers.fixed")}</p>}
{Object.keys(form.formState.errors).length > 0 && (
<p className="text-sm text-destructive" role="alert">
{t("printers.errors.INVALID_REQUEST")}
</p>
)}
<PrinterFeedback error={mutation.error ?? profiles.error ?? inventory.error} />
<ContextHelp
description={t("printers.description")}
examples={t("printers.examples")}
examplesLabel={t("mail.examplesLabel")}
guidance={t("printers.guidance")}
scopeNote={t("printers.scope")}
title={t("printers.help")}
/>
<FormActions>
<Button disabled={!ready || mutation.isPending} type="submit">
{t(mutation.isPending ? "saving" : "save")}
</Button>
</FormActions>
</form>
{quick === "profile" && (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
setQuick(undefined);
}
}}
open
title={t("profiles.new")}
wide
>
<WorkProfileForm
allowQuickCreate={false}
company={company}
csrfToken={csrfToken}
onSaved={(id) => {
if (mounted.current) {
form.setValue("profileId", id);
form.setValue("printerId", "");
setQuick(undefined);
}
}}
/>
</FormDialog>
)}
{quick === "printer" && (
<ConfigurationQuickEditor
company={company}
csrfToken={csrfToken}
data={undefined}
kind="asset"
onClose={() => setQuick(undefined)}
onCreated={(_field, id) => {
if (mounted.current && id) {
form.setValue("printerId", id);
setQuick(undefined);
}
}}
title={t("printers.newAsset")}
values={{ personId: "", accountId: "" }}
/>
)}
</>
);
}
function profileOptions(profiles: readonly WorkProfileDTO[], selected: string) {
return profiles
.filter((item) => item.values.active || item.id === selected)
.map((item) => ({ value: item.id, label: item.values.name }));
}
function printerOptions(assets: readonly AssetDTO[], hostId: string | undefined, selected: string) {
return assets
.filter(
(item) => item.id !== hostId && (item.values.status !== "retired" || item.id === selected)
)
.map((item) => ({
value: item.id,
label: `${item.values.name} · ${item.specification.templateName}`,
}));
}
function initialPrinterValues(
configuration: PrinterConfigurationDTO | undefined,
profileId: string
): PrinterConfigurationInput {
return (
configuration?.values ?? {
profileId,
printerId: "",
connection: "network",
isDefault: false,
state: "pending",
notes: null,
}
);
}
function availablePrinterStates(
configuration: PrinterConfigurationDTO | undefined
): readonly PrinterConfigurationInput["state"][] {
return configuration
? PRINTER_STATE_TRANSITIONS[configuration.values.state]
: ["pending", "configured"];
}

View File

@ -0,0 +1,192 @@
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
import { Button } from "@repo/shadcn-ui/components/button";
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { Input } from "@repo/shadcn-ui/components/input";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { CompanyDTO, PrinterConfigurationDTO } from "../../common";
import { profileApi, profileKeys } from "../profiles/work-profile-api";
import { printerApi, printerKeys } from "./printer-api";
import { PrinterConfigurationForm } from "./printer-configuration-form";
import { PrinterFeedback } from "./printer-feedback";
export function PrinterConfigurationsPage({
company,
csrfToken,
initialProfileId,
}: {
company: CompanyDTO;
csrfToken: string;
initialProfileId?: string;
}) {
const { t } = useTranslation("companies");
const [editor, setEditor] = useState<string>();
const [view, setView] = useState<string>();
const [search, setSearch] = useState("");
const query = useQuery({
queryKey: printerKeys.company(company.id),
queryFn: () => printerApi.get(company.id),
});
const profiles = useQuery({
queryKey: profileKeys.company(company.id),
queryFn: () => profileApi.get(company.id),
});
const inventory = useQuery({
queryKey: inventoryKeys.company(company.id),
queryFn: () => inventoryApi.get(company.id),
});
const configurations = query.data?.configurations ?? [];
const asset = (id: string) =>
inventory.data?.assets.find((item) => item.id === id)?.values.name ?? id;
const profile = (id: string) => profiles.data?.profiles.find((item) => item.id === id);
const label = (id: string) => {
const item = profile(id);
return item
? `${item.values.name} · ${company.people.find((person) => person.id === item.values.personId)?.name ?? ""} · ${asset(item.values.assetId)}`
: id;
};
const filtered = configurations.filter(
(item) =>
(!initialProfileId || item.values.profileId === initialProfileId) &&
`${asset(item.values.printerId)} ${label(item.values.profileId)}`
.toLocaleLowerCase()
.includes(search.trim().toLocaleLowerCase())
);
const edited = configurations.find((item) => item.id === editor);
const viewed = configurations.find((item) => item.id === view);
const ready = !!query.data && !!profiles.data && !!inventory.data;
const canCreate = !initialProfileId || profile(initialProfileId)?.values.active === true;
return (
<div className="space-y-5">
<header className="flex flex-wrap items-center justify-between gap-4">
<h2 className="text-xl font-semibold">{t("printers.title")}</h2>
<Button disabled={!(ready && canCreate)} onClick={() => setEditor("new")}>
{t("printers.new")}
</Button>
</header>
<Input
aria-label={t("printers.search")}
onChange={(event) => setSearch(event.target.value)}
placeholder={t("printers.search")}
value={search}
/>
{(query.isPending || profiles.isPending || inventory.isPending) && (
<p role="status">{t("loading")}</p>
)}
<PrinterFeedback error={query.error ?? profiles.error ?? inventory.error} />
{(query.isError || profiles.isError || inventory.isError) && (
<Button
onClick={() => {
Promise.all([query.refetch(), profiles.refetch(), inventory.refetch()]).catch(
() => undefined
);
}}
variant="outline"
>
{t("retry")}
</Button>
)}
{ready && (
<Card>
<CardContent className="divide-y pt-3">
{!filtered.length && (
<p className="py-4 text-muted-foreground">{t("printers.empty")}</p>
)}
{filtered.map((item) => (
<div className="flex flex-wrap justify-between gap-4 py-4" key={item.id}>
<div>
<h3 className="font-medium">{asset(item.values.printerId)}</h3>
<p className="text-sm text-muted-foreground">{label(item.values.profileId)}</p>
<p className="text-sm">
{t(`printers.${item.values.connection}`)} · {t(`mail.${item.values.state}`)}
</p>
{item.values.isDefault && (
<p className="text-sm font-medium">{t("printers.default")}</p>
)}
{item.needsReview && (
<p className="text-sm text-amber-700">{t("printers.review")}</p>
)}
</div>
<div className="flex items-start gap-2">
<Button onClick={() => setView(item.id)} size="sm" variant="outline">
{t("mail.history")}
</Button>
{!["removed", "never_configured"].includes(item.values.state) && (
<Button onClick={() => setEditor(item.id)} size="sm" variant="outline">
{t("edit")}
</Button>
)}
</div>
</div>
))}
</CardContent>
</Card>
)}
{editor && (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
setEditor(undefined);
}
}}
open
title={t(edited ? "printers.edit" : "printers.new")}
wide
>
<PrinterConfigurationForm
company={company}
configuration={edited}
csrfToken={csrfToken}
initialProfileId={initialProfileId ?? ""}
onSaved={() => setEditor(undefined)}
/>
</FormDialog>
)}
{viewed && (
<FormDialog
cancelLabel={t("close")}
onOpenChange={(open) => {
if (!open) {
setView(undefined);
}
}}
open
title={t("mail.history")}
wide
>
<PrinterHistory configuration={viewed} />
</FormDialog>
)}
</div>
);
}
function PrinterHistory({ configuration }: { configuration: PrinterConfigurationDTO }) {
const { t, i18n } = useTranslation("companies");
return (
<ol className="space-y-3">
{[...configuration.history].reverse().map((revision) => (
<li className="rounded-md border p-3 text-sm" key={revision.id}>
<p className="font-medium">
{new Intl.DateTimeFormat(i18n.language, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(revision.recordedAt))}{" "}
· {revision.actorName}
</p>
<p>
{t(`printers.${revision.values.connection}`)} · {t(`mail.${revision.values.state}`)}
</p>
<p>{t(revision.values.isDefault ? "printers.default" : "printers.notDefault")}</p>
{revision.values.notes && (
<p className="mt-2 whitespace-pre-wrap">{revision.values.notes}</p>
)}
</li>
))}
</ol>
);
}

View File

@ -0,0 +1,11 @@
import { useTranslation } from "react-i18next";
import { PrinterApiError } from "./printer-api";
export function PrinterFeedback({ error }: { error: Error | null }) {
const { t } = useTranslation("companies");
return error ? (
<p className="text-sm text-destructive" role="alert">
{t(`printers.errors.${error instanceof PrinterApiError ? error.code : "INTERNAL_ERROR"}`)}
</p>
) : null;
}

View File

@ -0,0 +1,86 @@
export const printersSpanish = {
title: "Impresoras del perfil",
"new": "Configurar impresora",
edit: "Editar configuración de impresora",
printer: "Impresora del inventario",
newAsset: "Nueva impresora en inventario",
connection: "Conexión",
usb: "USB",
network: "Red",
default: "Impresora predeterminada",
notDefault: "No predeterminada",
search: "Buscar impresora, perfil, persona o equipo…",
empty: "Todavía no hay impresoras configuradas en estos perfiles.",
review:
"Revisar esta configuración: hay una retirada pendiente o el perfil, persona o equipo ya no está disponible.",
fixed:
"El perfil y la impresora quedan fijos. Para otro destino, retira esta configuración y crea una nueva.",
help: "¿Para qué sirve esta configuración?",
description:
"Documenta qué impresoras necesita una persona en el perfil de su equipo y cómo se conectan.",
scope:
"La impresora es un activo de la compañía. Configurarla en un perfil no cambia su asignación ni crea una conexión física.",
examples:
"Ejemplo: impresora de administración por red y Zebra de etiquetas por USB en el portátil de Ana.",
guidance:
"Selecciona el activo que represente la impresora, cualquiera que sea su plantilla. Puede haber varias impresoras y una sola predeterminada por perfil. Las altas rápidas se conservan aunque canceles este formulario.",
errors: {
INVALID_REQUEST: "Revisa los datos del formulario.",
PRINTER_REFERENCE_NOT_FOUND:
"Selecciona un perfil y una impresora de esta compañía; la impresora debe ser distinta del equipo del perfil.",
PRINTER_REFERENCE_UNAVAILABLE:
"El perfil, la persona, el equipo o la impresora no están disponibles para configurar.",
PRINTER_TARGET_IMMUTABLE: "El perfil y la impresora no se pueden cambiar.",
PRINTER_ALREADY_OPEN: "Esta impresora ya tiene una configuración abierta en el perfil.",
DEFAULT_PRINTER_EXISTS:
"Ya hay otra impresora predeterminada en este perfil. Desmarca primero la anterior.",
INVALID_PRINTER_TRANSITION: "El cambio de estado no está permitido.",
PRINTER_CONFIGURATION_NOT_FOUND: "No se ha encontrado la configuración de impresora.",
COMPANY_NOT_FOUND: "No se ha encontrado la compañía.",
INTERNAL_ERROR: "No se ha podido completar la operación. Inténtalo de nuevo.",
FORBIDDEN: "La sesión no permite esta operación. Vuelve a iniciar sesión.",
UNAUTHENTICATED: "Vuelve a iniciar sesión.",
},
};
export const printersEnglish = {
title: "Profile printers",
"new": "Configure printer",
edit: "Edit printer configuration",
printer: "Inventory printer",
newAsset: "New inventory printer",
connection: "Connection",
usb: "USB",
network: "Network",
default: "Default printer",
notDefault: "Not default",
search: "Search printer, profile, person or device…",
empty: "No printers have been configured for these profiles yet.",
review:
"Review this configuration: removal is pending or the profile, person or device is unavailable.",
fixed:
"The profile and printer are fixed. For another target, retire this configuration and create a new one.",
help: "What is this configuration for?",
description: "Document the printers a person needs in their device profile and how they connect.",
scope:
"The printer is a company asset. Configuring it in a profile does not change its assignment or create a physical connection.",
examples: "Example: network administration printer and USB Zebra label printer on Ana's laptop.",
guidance:
"Select the asset representing the printer, regardless of its template. A profile may have several printers and one default. Quick additions remain saved if you cancel this form.",
errors: {
INVALID_REQUEST: "Check the form data.",
PRINTER_REFERENCE_NOT_FOUND:
"Select a profile and printer from this company; the printer must differ from the profile device.",
PRINTER_REFERENCE_UNAVAILABLE:
"The profile, person, device or printer is unavailable for configuration.",
PRINTER_TARGET_IMMUTABLE: "The profile and printer cannot change.",
PRINTER_ALREADY_OPEN: "This printer already has an open configuration in the profile.",
DEFAULT_PRINTER_EXISTS:
"This profile already has another default printer. Uncheck the previous one first.",
INVALID_PRINTER_TRANSITION: "This state change is not allowed.",
PRINTER_CONFIGURATION_NOT_FOUND: "Printer configuration not found.",
COMPANY_NOT_FOUND: "Company not found.",
INTERNAL_ERROR: "The operation could not be completed. Try again.",
FORBIDDEN: "Your session does not allow this operation. Sign in again.",
UNAUTHENTICATED: "Sign in again.",
},
};

View File

@ -0,0 +1,83 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { inventoryKeys } from "@metis/assets/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createInstance } from "i18next";
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nextProvider } from "react-i18next";
import { companiesEnglish, companiesSpanish } from "../i18n";
import { profileKeys } from "../profiles/work-profile-api";
import { printerKeys } from "./printer-api";
import { PrinterConfigurationForm } from "./printer-configuration-form";
import { PrinterConfigurationsPage } from "./printer-configurations-page";
const company = {
id: "0199b200-0000-7000-8000-000000000001",
name: "Empresa test",
active: true,
people: [],
contacts: [],
sites: [],
departments: [],
assignments: [],
};
async function render(language: string, node: ReactNode) {
const i18n = createInstance();
await i18n.init({
lng: language,
resources: { es: { companies: companiesSpanish }, en: { companies: companiesEnglish } },
});
const client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY } },
});
client.setQueryData(profileKeys.company(company.id), { profiles: [] });
client.setQueryData(inventoryKeys.company(company.id), {
assets: [],
relationships: [],
workstations: [],
});
client.setQueryData(printerKeys.company(company.id), { configurations: [] });
try {
return renderToStaticMarkup(
<I18nextProvider i18n={i18n}>
<QueryClientProvider client={client}>{node}</QueryClientProvider>
</I18nextProvider>
);
} finally {
client.clear();
}
}
test("impresoras: listado sin formularios, alta en cabecera y traducciones", async () => {
for (const [language, title, action] of [
["es", "Impresoras del perfil", "Configurar impresora"],
["en", "Profile printers", "Configure printer"],
]) {
const html = await render(
language ?? "es",
<PrinterConfigurationsPage company={company} csrfToken="test" />
);
assert.ok(html.includes(title ?? ""));
assert.ok(html.includes(action ?? ""));
assert.ok(!html.includes("<form"));
assert.ok(!html.includes("printers."));
}
});
test("impresoras: selectores con alta rápida y ayuda inferior antes de guardar", async () => {
for (const language of ["es", "en"]) {
const html = await render(
language,
<PrinterConfigurationForm company={company} csrfToken="test" onSaved={() => undefined} />
);
const text = language === "es" ? companiesSpanish : companiesEnglish;
assert.ok(html.includes(text.printers.printer));
assert.ok(html.includes(text.printers.default));
assert.ok(html.includes(text.printers.newAsset));
assert.ok(html.indexOf(text.printers.help) > html.indexOf(text.printers.connection));
assert.ok(html.indexOf(text.save) > html.indexOf(text.printers.help));
assert.ok(!html.includes("printers."));
}
});

View File

@ -1,4 +1,3 @@
import {FolderMappingsPage} from "../folders/folder-mappings-page";
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
import { Button } from "@repo/shadcn-ui/components/button";
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
@ -11,7 +10,9 @@ import { useTranslation } from "react-i18next";
import type { CompanyDTO } from "../../common";
import { accessApi, accessKeys } from "../access/access-api";
import { accessLabel } from "../access/access-labels";
import { FolderMappingsPage } from "../folders/folder-mappings-page";
import { MailConfigurationsPage } from "../mail/mail-configurations-page";
import { PrinterConfigurationsPage } from "../printers/printer-configurations-page";
import { ProfileFeedback } from "./profile-feedback";
import { profileApi, profileKeys } from "./work-profile-api";
@ -25,8 +26,10 @@ export function WorkProfilesPage({
csrfToken: string;
}) {
const { t, i18n } = useTranslation("companies");
const [folderProfile,setFolderProfile]=useState<string>();
const [mailProfile, setMailProfile] = useState<string>();
const [configuration, setConfiguration] = useState<{
kind: "folders" | "mail" | "printers";
profileId: string;
}>();
const [editor, setEditor] = useState<string>();
const [view, setView] = useState<string>();
const [search, setSearch] = useState("");
@ -63,20 +66,14 @@ export function WorkProfilesPage({
)
);
const ready = [query.data, inventory.data, access.data].every(Boolean);
if(folderProfile){return <div className="space-y-4"><Button variant="outline" onClick={()=>setFolderProfile(undefined)}>{t("profiles.title")}</Button>
<FolderMappingsPage company={company} csrfToken={csrfToken} initialProfileId={folderProfile} /></div>;}
if (mailProfile) {
if (configuration) {
return (
<div className="space-y-4">
<Button onClick={() => setMailProfile(undefined)} variant="outline">
{t("profiles.title")}
</Button>
<MailConfigurationsPage
company={company}
csrfToken={csrfToken}
initialProfileId={mailProfile}
/>
</div>
<ProfileConfigurations
company={company}
csrfToken={csrfToken}
onBack={() => setConfiguration(undefined)}
selection={configuration}
/>
);
}
return (
@ -141,8 +138,25 @@ export function WorkProfilesPage({
)}
</div>
<div className="flex flex-wrap gap-2">
<Button onClick={()=>setFolderProfile(item.id)} size="sm" variant="outline">{t("folders.title")}</Button>
<Button onClick={() => setMailProfile(item.id)} size="sm" variant="outline">
<Button
onClick={() => setConfiguration({ kind: "printers", profileId: item.id })}
size="sm"
variant="outline"
>
{t("printers.title")}
</Button>
<Button
onClick={() => setConfiguration({ kind: "folders", profileId: item.id })}
size="sm"
variant="outline"
>
{t("folders.title")}
</Button>
<Button
onClick={() => setConfiguration({ kind: "mail", profileId: item.id })}
size="sm"
variant="outline"
>
{t("mail.title")}
</Button>
<Button onClick={() => setView(item.id)} size="sm" variant="outline">
@ -241,3 +255,28 @@ export function WorkProfilesPage({
</div>
);
}
function ProfileConfigurations({
company,
csrfToken,
selection,
onBack,
}: {
company: CompanyDTO;
csrfToken: string;
selection: { kind: "folders" | "mail" | "printers"; profileId: string };
onBack: () => void;
}) {
const { t } = useTranslation("companies");
const props = { company, csrfToken, initialProfileId: selection.profileId };
return (
<div className="space-y-4">
<Button onClick={onBack} variant="outline">
{t("profiles.title")}
</Button>
{selection.kind === "folders" && <FolderMappingsPage {...props} />}
{selection.kind === "mail" && <MailConfigurationsPage {...props} />}
{selection.kind === "printers" && <PrinterConfigurationsPage {...props} />}
</div>
);
}

View File

@ -12,6 +12,7 @@ export function buildCatalogResponse(
id: item.id.toPrimitive(),
companyId: item.companyId.getOrUndefined()?.toPrimitive() ?? null,
builtin: item.stockKey.getOrUndefined() !== undefined,
builtinKey: item.stockKey.getOrUndefined() ?? null,
active: item.active,
effectiveActive: catalog.active(item, scope),
overridden: catalog.availability.some(

View File

@ -17,44 +17,126 @@ export function mapResourceInput(
if (previous && previous.values.toPrimitive().templateId !== input.templateId.toLowerCase()) {
return Result.fail(new ResourceCatalogError("TEMPLATE_FIXED"));
}
let specification = previous?.specification;
if (!specification) {
const template = context.catalog
.visible(Maybe.some(companyId))
.find((item) => item.id.toPrimitive() === input.templateId.toLowerCase());
if (
!template ||
template.values.data.kind !== "template" ||
!context.catalog.active(template, Maybe.some(companyId))
) {
return Result.fail(new ResourceCatalogError("TEMPLATE_UNAVAILABLE"));
}
const fields = template.values.data.fields.map((binding) => {
const field = context.catalog.definitions.find(
(item) => item.id.toPrimitive() === binding.fieldId
);
if (!field || field.values.data.kind !== "field") {
throw new Error("Plantilla persistida inconsistente.");
}
return {
fieldId: field.id.toPrimitive(),
name: field.values.data.name,
type: field.values.data.type,
unit: field.values.data.unit,
choices: [...field.values.data.choices],
required: binding.required,
};
});
const created = ResourceSpecification.create({
templateId: template.id.toPrimitive(),
templateName: template.values.data.name,
fields,
});
if (!created.isSuccess) {
return created;
}
specification = created.data;
const captured = previous
? Result.ok(previous.specification)
: captureSpecification(input, companyId, context);
if (!captured.isSuccess) {
return captured;
}
const values = ResourceValues.create(input, specification);
const specification = captured.data;
const compatibleInput = withMailboxIntegration(input, context);
const templateKey = context.catalog.definitions
.find((item) => item.id.toPrimitive() === input.templateId.toLowerCase())
?.stockKey.getOrUndefined();
if (templateKey === "template:access" && compatibleInput.integration?.kind !== "access") {
return Result.fail(new ResourceCatalogError("REFERENCE_UNAVAILABLE"));
}
const references = validateReferences(compatibleInput, context);
if (!references.isSuccess) {
return references;
}
const values = ResourceValues.create(compatibleInput, specification);
return values.isSuccess ? Result.ok({ specification, values: values.data }) : values;
}
function captureSpecification(
input: ResourceInput,
companyId: UniqueID,
context: InventoryContext
) {
const template = context.catalog
.visible(Maybe.some(companyId))
.find((item) => item.id.toPrimitive() === input.templateId.toLowerCase());
if (
!template ||
template.values.data.kind !== "template" ||
!context.catalog.active(template, Maybe.some(companyId))
) {
return Result.fail(new ResourceCatalogError("TEMPLATE_UNAVAILABLE"));
}
const fields = template.values.data.fields.map((binding) => {
const field = context.catalog.definitions.find(
(item) => item.id.toPrimitive() === binding.fieldId
);
if (!field || field.values.data.kind !== "field") {
throw new Error("Plantilla persistida inconsistente.");
}
return {
fieldId: field.id.toPrimitive(),
name: field.values.data.name,
type: field.values.data.type,
unit: field.values.data.unit,
choices: [...field.values.data.choices],
required: binding.required,
};
});
const created = ResourceSpecification.create({
templateId: template.id.toPrimitive(),
templateName: template.values.data.name,
fields,
});
if (!created.isSuccess) {
return created;
}
return created;
}
function withMailboxIntegration(input: ResourceInput, context: InventoryContext): ResourceInput {
const compatibleInput = { ...input };
if (!compatibleInput.integration) {
const template = context.catalog.definitions.find(
(item) => item.id.toPrimitive() === input.templateId.toLowerCase()
);
if (template?.stockKey.getOrUndefined() === "template:mailbox") {
const addressFieldId = context.catalog.definitions
.find((item) => item.stockKey.getOrUndefined() === "field:email")
?.id.toPrimitive();
const contextFieldId = context.catalog.definitions
.find((item) => item.stockKey.getOrUndefined() === "field:context")
?.id.toPrimitive();
if (!(addressFieldId && contextFieldId)) {
throw new Error("Campos de buzón ausentes.");
}
compatibleInput.integration = {
kind: "mailbox",
addressFieldId,
contextFieldId,
mailboxKind: "corporate",
accountId: null,
};
}
}
return compatibleInput;
}
function validateReferences(compatibleInput: ResourceInput, context: InventoryContext) {
const integration = compatibleInput.integration;
if (integration) {
const expected =
integration.kind === "mailbox"
? ([[integration.accountId, "account"]] as const)
: ([
[integration.resourceTypeId, "resource_type"],
[integration.systemId, "system"],
[integration.environmentId, "environment"],
] as const);
for (const [id, kind] of expected) {
if (
id &&
!context.references.some((item) => item.id === id.toLowerCase() && item.kind === kind)
) {
return Result.fail(new ResourceCatalogError("REFERENCE_UNAVAILABLE"));
}
}
if (
integration.kind === "access" &&
integration.environmentId &&
!context.references.some(
(item) =>
item.id === integration.environmentId?.toLowerCase() &&
item.systemId === integration.systemId
)
) {
return Result.fail(new ResourceCatalogError("REFERENCE_UNAVAILABLE"));
}
}
return Result.ok(true);
}

View File

@ -1,6 +1,7 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Result } from "@repo/rdx-utils";
import type { ResourceReferenceDTO } from "../../common/inventory";
import type { Resource } from "../domain/resource.aggregate";
import type { ResourceCatalog } from "../domain/resource-catalog.aggregate";
import type { ResourceCatalogError } from "../domain/resource-definition";
@ -9,6 +10,7 @@ export type InventoryContext = Readonly<{
catalog: ResourceCatalog;
resources: readonly Resource[];
actorNames: ReadonlyMap<string, string>;
references: readonly ResourceReferenceDTO[];
}>;
export interface IResourceRepository {
get(companyId: UniqueID): Promise<Result<InventoryContext, ResourceCatalogError>>;

View File

@ -34,11 +34,29 @@ export class ResourceUseCases {
if (!mapped.isSuccess) {
return Result.fail(mapped.error);
}
const current = mapped.data.values.toPrimitive();
if (
current.integration?.kind === "mailbox" &&
context.resources.some((item) => {
const old = item.values.toPrimitive();
return (
!item.id.equals(previous?.id) &&
old.integration?.kind === "mailbox" &&
old.characteristics[old.integration.addressFieldId] ===
current.characteristics[
current.integration?.kind === "mailbox" ? current.integration.addressFieldId : ""
]
);
})
) {
return Result.fail(new ResourceCatalogError("NAME_EXISTS"));
}
if (
context.resources.some(
(item) =>
!item.id.equals(previous?.id) &&
item.values.toPrimitive().templateId === mapped.data.values.toPrimitive().templateId &&
sameAccessContext(item.values.toPrimitive().integration, current.integration) &&
item.values.name.toPrimitive().toLocaleLowerCase("es") ===
mapped.data.values.name.toPrimitive().toLocaleLowerCase("es")
)
@ -58,3 +76,17 @@ export class ResourceUseCases {
});
}
}
function sameAccessContext(
first: ResourceInput["integration"],
second: ResourceInput["integration"]
) {
if (first?.kind !== "access" || second?.kind !== "access") {
return true;
}
return (
first.resourceTypeId === second.resourceTypeId &&
first.systemId === second.systemId &&
first.environmentId === second.environmentId
);
}

View File

@ -15,9 +15,11 @@ const FIELDS: readonly [string, string, ResourceFieldType][] = [
["expires", "Caducidad", "date"],
["fingerprint", "Huella digital", "text"],
["purpose", "Finalidad", "text"],
["reference", "Referencia", "text"],
];
const TEMPLATES: readonly [string, string, readonly string[]][] = [
["mailbox", "Buzón de correo", ["email", "provider", "context"]],
["access", "Recurso de acceso", ["reference"]],
["shared_folder", "Carpeta compartida", ["unc", "server", "description"]],
["application", "Aplicación", ["provider", "url", "description"]],
["certificate", "Certificado digital", ["holder", "issuer", "expires", "fingerprint", "purpose"]],

View File

@ -24,7 +24,7 @@ const field = {
} as const;
test("catálogo de recursos: serie idempotente, UUID v7, características opcionales y protección", () => {
const catalog = seedResourceCatalog(ResourceCatalog.create());
assert.equal(catalog.definitions.length, 16);
assert.equal(catalog.definitions.length, 18);
assert.equal(seedResourceCatalog(catalog), catalog);
assert.ok(catalog.definitions.every((item) => item.id.toPrimitive()[14] === "7"));
const template = catalog.definitions.find((item) => item.values.data.kind === "template");

View File

@ -15,12 +15,30 @@ export const ResourceValueSchema = z.union([
z.number().finite(),
z.boolean(),
]);
export const ResourceIntegrationSchema = z.discriminatedUnion("kind", [
z.strictObject({
kind: z.literal("mailbox"),
addressFieldId: z.uuid(),
contextFieldId: z.uuid(),
mailboxKind: z.enum(["corporate", "external"]),
accountId: z.uuid().nullable(),
}),
z.strictObject({
kind: z.literal("access"),
referenceFieldId: z.uuid(),
resourceTypeId: z.uuid(),
systemId: z.uuid().nullable(),
environmentId: z.uuid().nullable(),
}),
]);
export type ResourceIntegration = z.infer<typeof ResourceIntegrationSchema>;
export const ResourceInputSchema = z.strictObject({
name: z.string().min(1).max(100),
templateId: z.uuid(),
active: z.boolean(),
notes: z.string().max(10000),
characteristics: z.record(z.uuid(), ResourceValueSchema),
integration: ResourceIntegrationSchema.optional(),
});
export const ResourceSpecificationSchema = z.strictObject({
templateId: z.uuid(),

View File

@ -54,6 +54,24 @@ export class Resource extends AggregateRoot<ResourceProps> {
if (values.toPrimitive().templateId !== this.values.toPrimitive().templateId) {
return Result.fail(new ResourceCatalogError("TEMPLATE_FIXED"));
}
const previous = this.values.toPrimitive().integration;
const next = values.toPrimitive().integration;
if (
previous?.kind !== next?.kind ||
(previous?.kind === "mailbox" &&
next?.kind === "mailbox" &&
(previous.mailboxKind !== next.mailboxKind ||
previous.addressFieldId !== next.addressFieldId ||
previous.contextFieldId !== next.contextFieldId)) ||
(previous?.kind === "access" &&
next?.kind === "access" &&
(previous.referenceFieldId !== next.referenceFieldId ||
previous.resourceTypeId !== next.resourceTypeId ||
previous.systemId !== next.systemId ||
previous.environmentId !== next.environmentId))
) {
return Result.fail(new ResourceCatalogError("CONTEXT_FIXED"));
}
return Result.ok(
this.values.equals(values)
? this

View File

@ -106,3 +106,76 @@ test("recurso: identidad estable, historial, desactivación e idempotencia", ()
assert.ok(!changeTemplate.isSuccess);
assert.equal(changeTemplate.error.code, "TEMPLATE_FIXED");
});
test("buzón como recurso: correo válido, cuenta opcional, cambios auditados y tipo fijo", () => {
const addressFieldId = ids[0];
const contextFieldId = UniqueID.generateNewID().toPrimitive();
assert.ok(addressFieldId);
const spec = ResourceSpecification.create({
templateId,
templateName: "Buzón",
fields: [addressFieldId, contextFieldId].map((fieldId) => ({
fieldId,
name: fieldId,
type: "text" as const,
unit: "",
choices: [],
required: false,
})),
});
assert.ok(spec.isSuccess);
const mailbox: ResourceInput = {
...input({ [addressFieldId]: "ANA@EMPRESA.ES" }),
integration: {
kind: "mailbox",
addressFieldId,
contextFieldId,
mailboxKind: "corporate",
accountId: null,
},
};
const values = ResourceValues.create(mailbox, spec.data);
assert.ok(values.isSuccess);
assert.equal(values.data.toPrimitive().characteristics[addressFieldId], "ana@empresa.es");
assert.ok(Object.isFrozen(values.data.toPrimitive().integration));
const invalid = ResourceValues.create(
{ ...mailbox, characteristics: { [addressFieldId]: "no-es-correo" } },
spec.data
);
assert.ok(!invalid.isSuccess);
assert.equal(invalid.error.code, "INVALID_EMAIL");
const missing = ResourceValues.create({ ...mailbox, characteristics: {} }, spec.data);
assert.ok(!missing.isSuccess);
assert.equal(missing.error.code, "INVALID_EMAIL");
const actor = UniqueID.generateNewID();
const created = Resource.create(
UniqueID.generateNewID(),
spec.data,
values.data,
actor,
"2026-10-05T12:00:00.000Z"
);
assert.ok(created.isSuccess);
assert.ok(mailbox.integration?.kind === "mailbox");
const related = ResourceValues.create(
{
...mailbox,
integration: { ...mailbox.integration, accountId: UniqueID.generateNewID().toPrimitive() },
},
spec.data
);
assert.ok(related.isSuccess);
const updated = created.data.update(related.data, actor, "2026-10-05T13:00:00.000Z");
assert.ok(updated.isSuccess);
assert.equal(updated.data.history.length, 2);
assert.equal(updated.data.history[0]?.values.toPrimitive().integration?.kind, "mailbox");
assert.ok(updated.data.id.equals(created.data.id));
const external = ResourceValues.create(
{ ...mailbox, integration: { ...mailbox.integration, mailboxKind: "external" } },
spec.data
);
assert.ok(external.isSuccess);
const rejected = created.data.update(external.data, actor, "2026-10-05T14:00:00.000Z");
assert.ok(!rejected.isSuccess);
assert.equal(rejected.error.code, "CONTEXT_FIXED");
});

View File

@ -122,6 +122,11 @@ export class ResourceValues {
return parsedCharacteristics;
}
const characteristics = parsedCharacteristics.data;
const integration = data.integration;
const integrationError = validateIntegration(data, characteristics, specification);
if (integrationError) {
return Result.fail(new ResourceCatalogError(integrationError));
}
return Result.ok(
new ResourceValues(
Object.freeze({
@ -130,6 +135,7 @@ export class ResourceValues {
templateId: data.templateId.toLowerCase(),
notes: data.notes.trim(),
characteristics: Object.freeze(characteristics),
...(integration ? { integration: Object.freeze({ ...integration }) } : {}),
}),
name.data
)
@ -146,6 +152,7 @@ export class ResourceValues {
first.templateId === second.templateId &&
first.active === second.active &&
first.notes === second.notes &&
sameIntegration(first.integration, second.integration) &&
Object.keys(first.characteristics).length === Object.keys(second.characteristics).length &&
Object.entries(first.characteristics).every(
([key, value]) => value === second.characteristics[key]
@ -180,3 +187,82 @@ function normalizeCharacteristics(
}
return Result.ok(characteristics);
}
function sameIntegration(
first: ResourceInput["integration"],
second: ResourceInput["integration"]
) {
if (!(first && second)) {
return first === second;
}
if (first.kind === "mailbox" && second.kind === "mailbox") {
return (
first.addressFieldId === second.addressFieldId &&
first.contextFieldId === second.contextFieldId &&
first.mailboxKind === second.mailboxKind &&
first.accountId === second.accountId
);
}
return (
first.kind === "access" &&
second.kind === "access" &&
first.referenceFieldId === second.referenceFieldId &&
first.resourceTypeId === second.resourceTypeId &&
first.systemId === second.systemId &&
first.environmentId === second.environmentId
);
}
function validateIntegration(
data: ResourceInput,
characteristics: ResourceInput["characteristics"],
specification: ResourceSpecification
) {
const integration = data.integration;
if (!validIntegrationFields(integration, specification)) {
return "INVALID_CHARACTERISTICS" as const;
}
if (integration && data.notes.trim().length > 4000) {
return "INVALID_REQUEST" as const;
}
if (integration?.kind === "mailbox") {
const address = characteristics[integration.addressFieldId];
if (typeof address !== "string" || !z.email().max(254).safeParse(address).success) {
return "INVALID_EMAIL" as const;
}
characteristics[integration.addressFieldId] = address.toLowerCase();
const context = characteristics[integration.contextFieldId];
if (typeof context === "string" && context.length > 255) {
return "INVALID_CHARACTERISTICS" as const;
}
}
if (integration?.kind === "access") {
const reference = characteristics[integration.referenceFieldId];
if (typeof reference === "string" && reference.length > 255) {
return "INVALID_CHARACTERISTICS" as const;
}
}
return null;
}
function validIntegrationFields(
integration: ResourceInput["integration"],
specification: ResourceSpecification
) {
const fields =
integration?.kind === "mailbox"
? [integration.addressFieldId, integration.contextFieldId]
: integration?.kind === "access"
? [integration.referenceFieldId]
: [];
return !(
new Set(fields).size !== fields.length ||
fields.some(
(id) =>
!specification
.toPrimitive()
.fields.some((field) => field.fieldId === id && field.type === "text")
)
);
}

View File

@ -35,6 +35,7 @@ export function registerResourceRoutes(
return;
}
response.json({
references: result.data.references,
resources: result.data.resources.map((resource) =>
buildResourceResponse(resource, result.data.actorNames)
),

View File

@ -0,0 +1,244 @@
import { UniqueID } from "@repo/rdx-ddd";
import type { Sequelize, Transaction } from "sequelize";
import { QueryTypes } from "sequelize";
import { z } from "zod/v4";
import { Resource } from "../../domain/resource.aggregate";
import type { ResourceCatalog } from "../../domain/resource-catalog.aggregate";
import { ResourceRevision } from "../../domain/resource-revision.entity";
import type { ResourceInput } from "../../domain/resource-values";
import { ResourceInputSchema } from "../../domain/resource-values";
import { ResourceSpecification, ResourceValues } from "../../domain/value-objects/resource-values";
import { ResourceModel, ResourceRevisionModel } from "./resource.model";
import {
ResourceAvailabilityModel,
ResourceBindingModel,
ResourceCatalogModel,
ResourceDefinitionModel,
} from "./resource-catalog.model";
import { ResourceCatalogPersistenceMapper } from "./resource-catalog-persistence.mapper";
import { ResourcePersistenceMapper } from "./resource-persistence.mapper";
const mailboxSchema = z.strictObject({
address: z.email().max(254),
kind: z.enum(["corporate", "external"]),
context: z.string().max(255).nullable(),
accountId: z.uuid().nullable(),
active: z.boolean(),
notes: z.string().max(4000).nullable(),
});
const accessSchema = z.strictObject({
kind: z.literal("resource"),
name: z.string().min(1).max(100),
active: z.boolean(),
resourceTypeId: z.uuid(),
systemId: z.uuid().nullable(),
environmentId: z.uuid().nullable(),
reference: z.string().max(255).nullable(),
notes: z.string().max(4000).nullable(),
});
type SourceRow = { id: string; companyId: string; values: unknown };
type RevisionRow = {
id: string;
actorId: string;
recordedAt: string;
values: unknown;
position: number;
};
function json(value: unknown): unknown {
return typeof value === "string" ? JSON.parse(value) : value;
}
function identity(value: string) {
const id = UniqueID.create(value);
if (!id.isSuccess) {
throw new Error("Identidad de migración inválida.");
}
return id.data;
}
function definition(catalog: ResourceCatalog, key: string) {
const item = catalog.definitions.find((entry) => entry.stockKey.getOrUndefined() === key);
if (!item) {
throw new Error("Definición de migración ausente.");
}
return item;
}
function spec(catalog: ResourceCatalog, kind: "mailbox" | "access") {
const template = definition(catalog, `template:${kind}`);
if (template.values.data.kind !== "template") {
throw new Error("Plantilla de migración inválida.");
}
const fields = template.values.data.fields.map((binding) => {
const field = catalog.definitions.find((item) => item.id.toPrimitive() === binding.fieldId);
if (field?.values.data.kind !== "field") {
throw new Error("Campo de migración inválido.");
}
return {
fieldId: binding.fieldId,
name: field.values.data.name,
type: field.values.data.type,
unit: field.values.data.unit,
choices: [...field.values.data.choices],
required: binding.required,
};
});
const result = ResourceSpecification.create({
templateId: template.id.toPrimitive(),
templateName: template.values.data.name,
fields,
});
if (!result.isSuccess) {
throw new Error("Especificación de migración inválida.");
}
return result.data;
}
function map(catalog: ResourceCatalog, kind: "mailbox" | "access", value: unknown): ResourceInput {
const templateId = definition(catalog, `template:${kind}`).id.toPrimitive();
if (kind === "mailbox") {
const old = mailboxSchema.parse(json(value));
const addressFieldId = definition(catalog, "field:email").id.toPrimitive();
const contextFieldId = definition(catalog, "field:context").id.toPrimitive();
return {
name: old.address.slice(0, 100),
templateId,
active: old.active,
notes: old.notes ?? "",
characteristics: {
[addressFieldId]: old.address,
...(old.context ? { [contextFieldId]: old.context } : {}),
},
integration: {
kind: "mailbox",
mailboxKind: old.kind,
accountId: old.accountId,
addressFieldId,
contextFieldId,
},
};
}
const old = accessSchema.parse(json(value));
const referenceFieldId = definition(catalog, "field:reference").id.toPrimitive();
return {
name: old.name,
templateId,
active: old.active,
notes: old.notes ?? "",
characteristics: old.reference ? { [referenceFieldId]: old.reference } : {},
integration: {
kind: "access",
resourceTypeId: old.resourceTypeId,
systemId: old.systemId,
environmentId: old.environmentId,
referenceFieldId,
},
};
}
async function importResource(
database: Sequelize,
catalog: ResourceCatalog,
kind: "mailbox" | "access",
row: SourceRow,
transaction: Transaction
) {
const existing = await ResourceModel.findByPk(row.id, { transaction });
if (existing) {
const parsed = ResourceInputSchema.parse(json(existing.values));
if (!parsed || parsed.integration?.kind !== kind || existing.companyId !== row.companyId) {
throw new Error("Colisión de identidad al migrar recursos.");
}
return;
}
const table = kind === "mailbox" ? "mailbox_revisions" : "access_revisions";
const column = kind === "mailbox" ? "position" : "sequence";
const rows = await database.query<RevisionRow>(
`SELECT id,actorId,recordedAt,\`values\`,${column} AS position FROM ${table} WHERE itemId=:id ORDER BY ${column}`,
{ replacements: { id: row.id }, type: QueryTypes.SELECT, transaction }
);
const specification = spec(catalog, kind);
const values = ResourceValues.create(map(catalog, kind, row.values), specification);
if (!(values.isSuccess && rows.length)) {
throw new Error("Recurso anterior inconsistente.");
}
const history = rows.map((revision, index) => {
const previous = ResourceValues.create(map(catalog, kind, revision.values), specification);
if (
!previous.isSuccess ||
revision.position !== index ||
!z.iso.datetime().safeParse(revision.recordedAt).success ||
(index > 0 && revision.recordedAt < (rows[index - 1]?.recordedAt ?? ""))
) {
throw new Error("Historial anterior inconsistente.");
}
return ResourceRevision.rehydrate(
{
values: previous.data,
actorId: identity(revision.actorId),
recordedAt: revision.recordedAt,
},
identity(revision.id)
);
});
if (!history.at(-1)?.values.equals(values.data)) {
throw new Error("Última revisión anterior inconsistente.");
}
const resource = Resource.rehydrate(
{ companyId: identity(row.companyId), specification, values: values.data, history },
identity(row.id)
);
const saved = new ResourcePersistenceMapper().toPersistence(resource);
await ResourceModel.create(saved.resource, { transaction });
for (const revision of saved.revisions) {
await ResourceRevisionModel.create(revision, { transaction });
}
}
/** Traspaso idempotente por compañía: UUID y revisiones originales, sin inventar autoría. */
export async function migrateLegacyResources(database: Sequelize) {
await database.transaction(async (transaction) => {
const root = await ResourceCatalogModel.findOne({
where: { catalogKey: "global" },
transaction,
lock: transaction.LOCK.UPDATE,
});
if (!root) {
throw new Error("Catálogo de recursos ausente.");
}
const definitions = await ResourceDefinitionModel.findAll({
where: { catalogId: root.id },
transaction,
});
const bindings = await ResourceBindingModel.findAll({
where: { templateId: definitions.map((item) => item.id) },
transaction,
});
const availability = await ResourceAvailabilityModel.findAll({
where: { catalogId: root.id },
transaction,
});
const catalog = new ResourceCatalogPersistenceMapper().toDomain(
root,
definitions,
bindings,
availability
);
const companies = await database.query<{ id: string }>(
"SELECT id FROM companies ORDER BY id FOR UPDATE",
{ type: QueryTypes.SELECT, transaction }
);
for (const company of companies) {
const mailboxRows = await database.query<SourceRow>(
"SELECT id,companyId,`values` FROM mailboxes WHERE companyId=:companyId",
{ replacements: { companyId: company.id }, type: QueryTypes.SELECT, transaction }
);
const accessRows = await database.query<SourceRow>(
"SELECT i.id,w.companyId,i.`values` FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId WHERE i.kind='resource' AND w.companyId=:companyId",
{ replacements: { companyId: company.id }, type: QueryTypes.SELECT, transaction }
);
for (const row of mailboxRows) {
await importResource(database, catalog, "mailbox", row, transaction);
}
for (const row of accessRows) {
await importResource(database, catalog, "access", row, transaction);
}
}
});
}

View File

@ -0,0 +1,134 @@
import { UniqueID } from "@repo/rdx-ddd";
import type { Sequelize, Transaction } from "sequelize";
import { QueryTypes } from "sequelize";
import type { Resource } from "../../domain/resource.aggregate";
import type { ResourceInput } from "../../domain/resource-values";
/** Proyecciones para los contratos de accesos y perfiles: la ficha editable pertenece a resources. */
export async function projectResource(
database: Sequelize,
resource: Resource,
transaction: Transaction
) {
const current = resource.values.toPrimitive();
const integration = current.integration;
if (!integration) {
return;
}
const companyId = resource.companyId.toPrimitive();
const id = resource.id.toPrimitive();
if (integration.kind === "mailbox") {
const values = mailboxProjection(current);
await database.query(
"INSERT INTO mailboxes (id,companyId,accountId,addressKey,`values`) VALUES (:id,:companyId,:accountId,:addressKey,:values) ON DUPLICATE KEY UPDATE accountId=VALUES(accountId),addressKey=VALUES(addressKey),`values`=VALUES(`values`)",
{
replacements: {
id,
companyId,
accountId: values.accountId,
addressKey: `${companyId}:${values.address}`,
values: JSON.stringify(values),
},
transaction,
}
);
for (const [position, revision] of resource.history.entries()) {
await database.query(
"INSERT INTO mailbox_revisions (id,itemId,position,actorId,recordedAt,`values`) VALUES (:id,:itemId,:position,:actorId,:recordedAt,:values) ON DUPLICATE KEY UPDATE id=id",
{
replacements: {
id: revision.id.toPrimitive(),
itemId: id,
position,
actorId: revision.actorId.toPrimitive(),
recordedAt: revision.recordedAt,
values: JSON.stringify(mailboxProjection(revision.values.toPrimitive())),
},
transaction,
}
);
}
return;
}
const roots = await database.query<{ id: string }>(
"SELECT id FROM access_workspaces WHERE companyId=:companyId",
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
);
let workspaceId = roots[0]?.id;
if (!workspaceId) {
workspaceId = UniqueID.generateNewID().toPrimitive();
await database.query(
"INSERT INTO access_workspaces (id,companyId,scopeKey) VALUES (:id,:companyId,:companyId)",
{ replacements: { id: workspaceId, companyId }, transaction }
);
}
const values = accessProjection(current);
await database.query(
"INSERT INTO access_items (id,workspaceId,kind,`values`) VALUES (:id,:workspaceId,'resource',:values) ON DUPLICATE KEY UPDATE `values`=VALUES(`values`)",
{ replacements: { id, workspaceId, values: JSON.stringify(values) }, transaction }
);
for (const [sequence, revision] of resource.history.entries()) {
await database.query(
"INSERT INTO access_revisions (id,itemId,sequence,actorId,recordedAt,`values`) VALUES (:id,:itemId,:sequence,:actorId,:recordedAt,:values) ON DUPLICATE KEY UPDATE id=id",
{
replacements: {
id: revision.id.toPrimitive(),
itemId: id,
sequence,
actorId: revision.actorId.toPrimitive(),
recordedAt: revision.recordedAt,
values: JSON.stringify(accessProjection(revision.values.toPrimitive())),
},
transaction,
}
);
}
// El contexto es fijo; estas referencias preservan las FK utilizadas por permisos y necesidades.
for (const targetId of [
integration.resourceTypeId,
integration.systemId,
integration.environmentId,
].filter(Boolean)) {
const existing = await database.query<{ itemId: string }>(
"SELECT itemId FROM access_references WHERE itemId=:id AND targetId=:targetId",
{ replacements: { id, targetId }, transaction, type: QueryTypes.SELECT }
);
if (!existing.length) {
await database.query(
"INSERT INTO access_references (itemId,targetId) VALUES (:id,:targetId)",
{ replacements: { id, targetId }, transaction }
);
}
}
}
function mailboxProjection(input: ResourceInput) {
const integration = input.integration;
if (integration?.kind !== "mailbox") {
throw new Error("Recurso sin contexto de buzón.");
}
return {
address: input.characteristics[integration.addressFieldId],
kind: integration.mailboxKind,
context: input.characteristics[integration.contextFieldId] || null,
accountId: integration.accountId,
active: input.active,
notes: input.notes || null,
};
}
function accessProjection(input: ResourceInput) {
const integration = input.integration;
if (integration?.kind !== "access") {
throw new Error("Recurso sin contexto de acceso.");
}
return {
kind: "resource",
name: input.name,
active: input.active,
resourceTypeId: integration.resourceTypeId,
systemId: integration.systemId,
environmentId: integration.environmentId,
reference: input.characteristics[integration.referenceFieldId] || null,
notes: input.notes || null,
};
}

View File

@ -2,7 +2,9 @@ import type { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { Sequelize, Transaction } from "sequelize";
import { QueryTypes } from "sequelize";
import { z } from "zod/v4";
import { ResourceReferenceSchema } from "../../../common/inventory";
import type { IResourceRepository, InventoryContext } from "../../application/resource.repository";
import { ResourceCatalogError } from "../../domain/resource-definition";
@ -14,6 +16,7 @@ import {
ResourceDefinitionModel,
} from "./resource-catalog.model";
import { ResourceCatalogPersistenceMapper } from "./resource-catalog-persistence.mapper";
import { projectResource } from "./resource-compatibility";
import { ResourcePersistenceMapper } from "./resource-persistence.mapper";
export class SequelizeResourceRepository implements IResourceRepository {
@ -100,7 +103,30 @@ export class SequelizeResourceRepository implements IResourceRepository {
"SELECT id, name FROM users",
{ type: QueryTypes.SELECT, transaction }
);
const rawReferences = await this.database.query<{ id: string; values: unknown }>(
"SELECT i.id,i.values FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId WHERE (w.companyId=:companyId OR w.companyId IS NULL) AND i.kind IN ('account','system','environment','resource_type')",
{ replacements: { companyId: companyId.toPrimitive() }, transaction, type: QueryTypes.SELECT }
);
const references = rawReferences.map((row) => {
const data = z
.object({
kind: z.enum(["account", "system", "environment", "resource_type"]),
name: z.string().optional(),
identifier: z.string().optional(),
active: z.boolean(),
systemId: z.uuid().optional(),
})
.parse(typeof row.values === "string" ? JSON.parse(row.values) : row.values);
return ResourceReferenceSchema.parse({
id: row.id,
kind: data.kind,
name: data.name ?? data.identifier ?? "",
active: data.active,
systemId: data.systemId ?? null,
});
});
return Result.ok({
references,
catalog,
resources,
actorNames: new Map(actors.map((actor) => [actor.id, actor.name])),
@ -120,6 +146,7 @@ export class SequelizeResourceRepository implements IResourceRepository {
return changed;
}
const persistence = this.mapper.toPersistence(changed.data);
await projectResource(this.database, changed.data, transaction);
await ResourceModel.upsert(persistence.resource, { transaction });
const existing = await ResourceRevisionModel.findAll({
where: { resourceId: persistence.resource.id },

View File

@ -8,6 +8,7 @@ import { ResourceCatalogUseCases } from "./application/resource-catalog.use-case
import { seedResourceCatalog } from "./domain/resource-catalog-seed";
import { registerResourceRoutes } from "./infrastructure/http/resource.routes";
import { registerResourceCatalogRoutes } from "./infrastructure/http/resource-catalog.routes";
import { migrateLegacyResources } from "./infrastructure/persistence/migrate-legacy-resources";
import { migrateInventory } from "./infrastructure/persistence/resource.model";
import {
ResourceCatalogModel,
@ -37,6 +38,7 @@ export async function createResourceModule(database: Sequelize, authenticate: Re
const catalog = new ResourceCatalogUseCases(repository);
const inventory = new ResourceUseCases(new SequelizeResourceRepository(database));
return {
migrateLegacy: () => migrateLegacyResources(database),
register: (app: Parameters<typeof registerResourceCatalogRoutes>[0]) => {
registerResourceCatalogRoutes(app, catalog, authenticate);
registerResourceRoutes(app, inventory, authenticate);

View File

@ -15,6 +15,7 @@ export const CatalogItemSchema = z.strictObject({
id: z.uuid(),
companyId: z.uuid().nullable(),
builtin: z.boolean(),
builtinKey: z.string().nullable().optional(),
active: z.boolean(),
effectiveActive: z.boolean(),
overridden: z.boolean(),

View File

@ -26,8 +26,17 @@ export const ResourceSchema = z.strictObject({
values: ResourceInputSchema,
history: z.array(ResourceRevisionSchema).min(1),
});
export const ResourceReferenceSchema = z.strictObject({
id: z.uuid(),
kind: z.enum(["account", "system", "environment", "resource_type"]),
name: z.string(),
active: z.boolean(),
systemId: z.uuid().nullable(),
});
export type ResourceReferenceDTO = z.infer<typeof ResourceReferenceSchema>;
export const InventoryResponseSchema = z.strictObject({
resources: z.array(ResourceSchema),
references: z.array(ResourceReferenceSchema).optional(),
});
export const ResourceResponseSchema = z.strictObject({ resource: ResourceSchema });
export type ResourceDTO = z.infer<typeof ResourceSchema>;

View File

@ -1,3 +1,6 @@
export { catalogKeys } from "./catalog-api";
export { ResourceCatalogPage } from "./catalog-page";
export { resourcesEnglish, resourcesSpanish } from "./i18n";
export { inventoryKeys } from "./inventory-api";
export { ResourceEditor } from "./resource-editor";
export { ResourcesPage } from "./resources-page";

View File

@ -1,5 +1,12 @@
export const inventorySpanish = {
inventory: "Recursos",
mailboxKind: "Tipo de buzón",
corporate: "Corporativo",
external: "Externo",
relatedAccount: "Cuenta relacionada (opcional)",
resourceTypeId: "Tipo de acceso",
systemId: "Sistema (opcional)",
environmentId: "Entorno (opcional)",
newResource: "Nuevo recurso",
editResource: "Editar recurso",
viewResource: "Ficha e historial",
@ -26,6 +33,9 @@ export const inventorySpanish = {
relatedNotice: "La plantilla se guardará aunque después canceles el recurso.",
inventoryErrors: {
INVALID_REQUEST: "Revisa los datos del formulario.",
INVALID_EMAIL: "Introduce una dirección de correo válida.",
CONTEXT_FIXED: "El tipo y contexto del recurso no se pueden cambiar.",
REFERENCE_UNAVAILABLE: "Selecciona referencias válidas de esta compañía.",
INVALID_CHARACTERISTICS:
"Una característica no pertenece a la plantilla o tiene un valor inválido.",
REQUIRED_CHARACTERISTICS: "Completa las características obligatorias de la plantilla.",
@ -41,6 +51,13 @@ export const inventorySpanish = {
};
export const inventoryEnglish = {
inventory: "Resources",
mailboxKind: "Mailbox type",
corporate: "Corporate",
external: "External",
relatedAccount: "Related account (optional)",
resourceTypeId: "Access type",
systemId: "System (optional)",
environmentId: "Environment (optional)",
newResource: "New resource",
editResource: "Edit resource",
viewResource: "Details and history",
@ -66,6 +83,9 @@ export const inventoryEnglish = {
relatedNotice: "The template remains saved if you later cancel the resource.",
inventoryErrors: {
INVALID_REQUEST: "Check the form data.",
INVALID_EMAIL: "Enter a valid email address.",
CONTEXT_FIXED: "The resource type and context cannot change.",
REFERENCE_UNAVAILABLE: "Select valid references from this company.",
INVALID_CHARACTERISTICS:
"A characteristic is not part of the template or has an invalid value.",
REQUIRED_CHARACTERISTICS: "Complete the required template characteristics.",

View File

@ -1,8 +1,14 @@
import { useTranslation } from "react-i18next";
import type { ResourceDTO } from "../common/inventory";
import type { ResourceDTO, ResourceInput, ResourceReferenceDTO } from "../common/inventory";
export function ResourceDetail({ resource }: { resource: ResourceDTO }) {
export function ResourceDetail({
resource,
references = [],
}: {
resource: ResourceDTO;
references?: readonly ResourceReferenceDTO[];
}) {
const { t, i18n } = useTranslation("resources");
const display = (value: string | number | boolean | undefined) =>
value === undefined
@ -34,6 +40,7 @@ export function ResourceDetail({ resource }: { resource: ResourceDTO }) {
</div>
))}
</dl>
<IntegrationSummary integration={resource.values.integration} references={references} />
{resource.values.notes && (
<p className="whitespace-pre-wrap break-words">{resource.values.notes}</p>
)}
@ -63,6 +70,7 @@ export function ResourceDetail({ resource }: { resource: ResourceDTO }) {
</div>
))}
</dl>
<IntegrationSummary integration={revision.values.integration} references={references} />
{revision.values.notes && (
<p className="whitespace-pre-wrap break-words">{revision.values.notes}</p>
)}
@ -72,3 +80,46 @@ export function ResourceDetail({ resource }: { resource: ResourceDTO }) {
</div>
);
}
function IntegrationSummary({
integration,
references,
}: {
integration: ResourceInput["integration"];
references: readonly ResourceReferenceDTO[];
}) {
const { t } = useTranslation("resources");
if (!integration) {
return null;
}
const entries =
integration.kind === "mailbox"
? [
{ label: t("mailboxKind"), value: t(integration.mailboxKind) },
{
label: t("relatedAccount"),
value: referenceName(integration.accountId, references, t("noValue")),
},
]
: (["resourceTypeId", "systemId", "environmentId"] as const).map((key) => ({
label: t(key),
value: referenceName(integration[key], references, t("noValue")),
}));
return (
<dl className="grid gap-2 sm:grid-cols-2">
{entries.map((entry) => (
<div key={entry.label}>
<dt className="text-sm text-muted-foreground">{entry.label}</dt>
<dd>{entry.value}</dd>
</div>
))}
</dl>
);
}
function referenceName(
id: string | null,
references: readonly ResourceReferenceDTO[],
missing: string
) {
return id ? (references.find((item) => item.id === id)?.name ?? id) : missing;
}

View File

@ -0,0 +1,70 @@
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { catalogApi, catalogKeys } from "./catalog-api";
import { inventoryApi, inventoryKeys } from "./inventory-api";
import { InventoryFeedback, ResourceForm } from "./resource-form";
export function ResourceEditor({
companyId,
csrfToken,
resourceId,
initialKind,
initialAddress = "",
initialAccess,
onSaved,
allowQuickCreate = true,
}: {
companyId: string;
csrfToken: string;
resourceId?: string | undefined;
initialKind?: "mailbox" | "access" | undefined;
initialAddress?: string;
initialAccess?:
| { resourceTypeId: string; systemId: string | null; environmentId: string | null }
| undefined;
onSaved: (id: string) => void;
allowQuickCreate?: boolean;
}) {
const { t } = useTranslation("resources");
const catalog = useQuery({
queryKey: catalogKeys.scope(companyId),
queryFn: () => catalogApi.get(companyId),
});
const inventory = useQuery({
queryKey: inventoryKeys.company(companyId),
queryFn: () => inventoryApi.get(companyId),
});
if (catalog.isPending || inventory.isPending) {
return <p role="status">{t("loading")}</p>;
}
if (catalog.error || inventory.error) {
return <InventoryFeedback error={catalog.error ?? inventory.error} />;
}
const resource = inventory.data?.resources.find((item) => item.id === resourceId);
if (resourceId && !resource) {
return <p role="alert">{t("inventoryErrors.RESOURCE_NOT_FOUND")}</p>;
}
if (!catalog.data) {
return null;
}
const referenceFieldId = catalog.data.items.find(
(item) => item.builtinKey === "field:reference"
)?.id;
const initialIntegration =
initialAccess && referenceFieldId
? { kind: "access" as const, referenceFieldId, ...initialAccess }
: undefined;
return (
<ResourceForm
allowQuickCreate={allowQuickCreate}
companyId={companyId}
csrfToken={csrfToken}
initialAddress={initialAddress}
initialIntegration={initialIntegration}
initialKind={initialKind}
items={catalog.data.items}
onSaved={onSaved}
resource={resource}
/>
);
}

View File

@ -6,7 +6,7 @@ import { Input } from "@repo/shadcn-ui/components/input";
import { Label } from "@repo/shadcn-ui/components/label";
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
import { Textarea } from "@repo/shadcn-ui/components/textarea";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useId, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
@ -18,6 +18,7 @@ import { ResourceInputSchema } from "../common/inventory";
import { CatalogTemplateForm } from "./catalog-template-form";
import { InventoryApiError, inventoryApi, inventoryKeys } from "./inventory-api";
import { ResourceCharacteristicControl } from "./resource-characteristic-control";
import { ResourceIntegrationFields, mailboxIntegration } from "./resource-integration-fields";
export function InventoryFeedback({ error }: { error: Error | null }) {
const { t } = useTranslation("resources");
return error ? (
@ -33,6 +34,9 @@ export function ResourceForm({
items,
onSaved,
allowQuickCreate = true,
initialKind,
initialAddress = "",
initialIntegration,
}: {
companyId: string;
csrfToken: string;
@ -40,6 +44,9 @@ export function ResourceForm({
items: readonly CatalogItemDTO[];
onSaved: (id: string) => void;
allowQuickCreate?: boolean;
initialKind?: "mailbox" | "access" | undefined;
initialAddress?: string;
initialIntegration?: ResourceInput["integration"];
}) {
const { t } = useTranslation("resources");
const prefix = useId();
@ -52,43 +59,37 @@ export function ResourceForm({
mounted.current = false;
};
}, []);
const referenceQuery = useQuery({
queryKey: inventoryKeys.company(companyId),
queryFn: () => inventoryApi.get(companyId),
});
const form = useForm<ResourceInput>({
resolver: zodResolver(ResourceInputSchema),
defaultValues: resource?.values ?? {
name: "",
templateId: "",
active: true,
notes: "",
characteristics: {},
},
defaultValues: resourceDefaults(
items,
resource,
initialKind,
initialAddress,
initialIntegration
),
});
const values = form.watch();
const templates = items.filter((item) => item.values.kind === "template" && item.effectiveActive);
const template = templates.find((item) => item.id === values.templateId);
const fields =
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,
},
]
: [];
})
: []);
const fields = resourceFields(resource, template, items);
const mutation = useMutation({
mutationFn: (input: ResourceInput) =>
inventoryApi.save(companyId, input, csrfToken, resource?.id),
onSuccess: async (response) => {
await client.invalidateQueries({ queryKey: inventoryKeys.company(companyId) });
await Promise.all(
[
inventoryKeys.company(companyId),
["mailboxes"],
["mail-configurations"],
["access"],
["folder-mappings"],
].map((queryKey) => client.invalidateQueries({ queryKey }))
);
if (mounted.current) {
onSaved(response.resource.id);
}
@ -108,7 +109,20 @@ export function ResourceForm({
};
return (
<>
<form className="space-y-5" onSubmit={form.handleSubmit((input) => mutation.mutate(input))}>
<form
className="space-y-5"
onSubmit={form.handleSubmit((input) => {
if (template?.builtinKey === "template:access" && input.integration?.kind !== "access") {
form.setError("integration", { message: t("inventoryErrors.REFERENCE_UNAVAILABLE") });
return;
}
const integration =
template?.builtinKey === "template:mailbox"
? mailboxIntegration(items, input.integration)
: input.integration;
mutation.mutate(integration ? { ...input, integration } : input);
})}
>
<fieldset className="space-y-5" disabled={mutation.isPending}>
<div className="space-y-2">
<Label htmlFor={`${prefix}-name`}>{t("name")}</Label>
@ -129,6 +143,7 @@ export function ResourceForm({
onValueChange={(templateId) => {
form.setValue("templateId", templateId);
form.setValue("characteristics", {});
form.setValue("integration", undefined);
}}
options={
resource
@ -163,6 +178,16 @@ export function ResourceForm({
/>
</div>
</div>
<ResourceIntegrationFields
fixed={!!resource}
integration={values.integration}
items={items}
onChange={(integration) =>
form.setValue("integration", integration, { shouldDirty: true })
}
references={referenceQuery.data?.references ?? []}
templateKey={template?.builtinKey}
/>
{fields.length > 0 && (
<section className="space-y-3">
<h3 className="font-medium">{t("characteristics")}</h3>
@ -170,7 +195,7 @@ export function ResourceForm({
{fields.map((field) => (
<ResourceCharacteristicControl
disabled={mutation.isPending}
field={field}
field={requireMailboxAddress(field, items, values, template)}
key={field.fieldId}
onChange={(value) => setCharacteristic(field.fieldId, value)}
prefix={prefix}
@ -184,7 +209,9 @@ export function ResourceForm({
<Label htmlFor={`${prefix}-notes`}>{t("notes")}</Label>
<Textarea
id={`${prefix}-notes`}
maxLength={10000}
maxLength={
values.integration || template?.builtinKey === "template:mailbox" ? 4000 : 10000
}
rows={3}
{...form.register("notes")}
/>
@ -195,7 +222,7 @@ export function ResourceForm({
{t("inventoryErrors.INVALID_REQUEST")}
</p>
)}
<InventoryFeedback error={mutation.error} />
<InventoryFeedback error={mutation.error ?? referenceQuery.error} />
<ContextHelp
description={t("resourceDescription")}
examples={t("resourceExamples")}
@ -240,3 +267,72 @@ export function ResourceForm({
</>
);
}
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<typeof resourceFields>[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 } : {},
};
}

View File

@ -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 (
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`${prefix}-kind`}>{t("mailboxKind")}</Label>
<SelectWithAction
disabled={fixed}
id={`${prefix}-kind`}
label={t("mailboxKind")}
onValueChange={(value) => {
if (value === "corporate" || value === "external") {
onChange({ ...mailbox, mailboxKind: value });
}
}}
options={["corporate", "external"].map((value) => ({ value, label: t(value) }))}
placeholder={t("chooseValue")}
value={mailbox.mailboxKind}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-account`}>{t("relatedAccount")}</Label>
<SelectWithAction
id={`${prefix}-account`}
label={t("relatedAccount")}
onValueChange={(value) =>
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"}
/>
</div>
</div>
);
}
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 (
<div className="grid gap-4 sm:grid-cols-2">
{(["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 (
<div className="space-y-2" key={key}>
<Label htmlFor={prefix + key}>{t(key)}</Label>
<SelectWithAction
disabled={
fixed ||
(key === "environmentId" && !access?.systemId) ||
(key !== "resourceTypeId" && !access)
}
id={prefix + key}
label={t(key)}
onValueChange={(value) => {
if (key !== "resourceTypeId" || value !== "none") {
change(key, value);
}
}}
options={[{ value: "none", label: t("noValue") }, ...options]}
placeholder={t("chooseValue")}
value={selected}
/>
</div>
);
})}
</div>
);
}
function optionalReference(value: string) {
return value === "none" ? null : value;
}

View File

@ -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
>
<ResourceDetail resource={viewed} />
<ResourceDetail references={query.data?.references ?? []} resource={viewed} />
{relatedContent?.(viewed)}
</FormDialog>
)}
</div>