Impresoras en perfil de usuario
This commit is contained in:
parent
03c1c30146
commit
f957dd40cf
@ -17,6 +17,7 @@ import {
|
|||||||
} from "@metis/identity/common";
|
} from "@metis/identity/common";
|
||||||
import { createResourceModule } from "@metis/resources/module";
|
import { createResourceModule } from "@metis/resources/module";
|
||||||
import { QueryTypes } from "sequelize";
|
import { QueryTypes } from "sequelize";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
import { verifyAccess } from "./access.integration";
|
import { verifyAccess } from "./access.integration";
|
||||||
import { createApp } from "./app";
|
import { createApp } from "./app";
|
||||||
@ -25,7 +26,9 @@ import { createDatabase, readDatabaseEnvironment } from "./config/database";
|
|||||||
import { createIdentityRuntime } from "./infrastructure/http/identity-runtime";
|
import { createIdentityRuntime } from "./infrastructure/http/identity-runtime";
|
||||||
import { verifyInventory } from "./inventory.integration";
|
import { verifyInventory } from "./inventory.integration";
|
||||||
import { verifyMail } from "./mail.integration";
|
import { verifyMail } from "./mail.integration";
|
||||||
|
import { verifyPrinters } from "./printers.integration";
|
||||||
import { verifyRelationships } from "./relationships.integration";
|
import { verifyRelationships } from "./relationships.integration";
|
||||||
|
import { verifyResourceMigration } from "./resource-migration.integration";
|
||||||
import { verifyResources } from "./resources.integration";
|
import { verifyResources } from "./resources.integration";
|
||||||
import { verifyWorkProfiles } from "./work-profiles.integration";
|
import { verifyWorkProfiles } from "./work-profiles.integration";
|
||||||
import { verifyWorkstations } from "./workstations.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.
|
// Este proceso solo puede limpiar la base de pruebas dedicada.
|
||||||
assert.equal(environment.DB_NAME, "metis_desk_test");
|
assert.equal(environment.DB_NAME, "metis_desk_test");
|
||||||
const database = createDatabase(environment);
|
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");
|
const server = createApp(() => undefined, identity).listen(0, "127.0.0.1");
|
||||||
try {
|
try {
|
||||||
await once(server, "listening");
|
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 mailboxes");
|
||||||
await database.query("DELETE FROM folder_mapping_revisions");
|
await database.query("DELETE FROM folder_mapping_revisions");
|
||||||
await database.query("DELETE FROM folder_mappings");
|
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_profile_revisions");
|
||||||
await database.query("DELETE FROM work_profiles");
|
await database.query("DELETE FROM work_profiles");
|
||||||
await database.query("DELETE FROM workstation_assets");
|
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(
|
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"
|
"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(), true);
|
||||||
await createCompanyModule(database, (_request, _response, next) => next());
|
await createCompanyModule(database, (_request, _response, next) => next(), true);
|
||||||
const legacy = await database.query<{ id: string; email: string; isContact: number }>(
|
const legacy = await database.query<{ id: string; email: string; isContact: number }>(
|
||||||
"SELECT id,email,isContact FROM company_contacts WHERE id=:id",
|
"SELECT id,email,isContact FROM company_contacts WHERE id=:id",
|
||||||
{ replacements: { id: legacyPersonId }, type: QueryTypes.SELECT }
|
{ 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,
|
secondCompany.id,
|
||||||
database
|
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 }>(
|
const countAfterAccess = await database.query<{ total: number }>(
|
||||||
"SELECT COUNT(*) AS total FROM users",
|
"SELECT COUNT(*) AS total FROM users",
|
||||||
{ type: QueryTypes.SELECT }
|
{ type: QueryTypes.SELECT }
|
||||||
|
|||||||
@ -10,16 +10,22 @@ import type { readDatabaseEnvironment } from "../../config/database";
|
|||||||
|
|
||||||
export async function createIdentityRuntime(
|
export async function createIdentityRuntime(
|
||||||
database: Sequelize,
|
database: Sequelize,
|
||||||
environment: ReturnType<typeof readDatabaseEnvironment>
|
environment: ReturnType<typeof readDatabaseEnvironment>,
|
||||||
|
options: { legacyResourceFixtures?: boolean } = {}
|
||||||
) {
|
) {
|
||||||
await database.authenticate();
|
await database.authenticate();
|
||||||
await migrateIdentity(database);
|
await migrateIdentity(database);
|
||||||
const identity = await createIdentityModule(database, environment.APP_ORIGIN);
|
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 assets = await createAssetModule(database, identity.authenticate);
|
||||||
const resources = await createResourceModule(database, identity.authenticate);
|
const resources = await createResourceModule(database, identity.authenticate);
|
||||||
const mail = await companies.initializeMail(assets.services.inventory);
|
const mail = await companies.initializeMail(assets.services.inventory);
|
||||||
const profiles = await companies.initializeProfiles(assets.services.inventory);
|
const profiles = await companies.initializeProfiles(assets.services.inventory);
|
||||||
|
await resources.migrateLegacy();
|
||||||
const SequelizeStore = connectSequelize(session.Store);
|
const SequelizeStore = connectSequelize(session.Store);
|
||||||
const store = new SequelizeStore({
|
const store = new SequelizeStore({
|
||||||
db: database,
|
db: database,
|
||||||
|
|||||||
173
apps/server/src/printers.integration.ts
Normal file
173
apps/server/src/printers.integration.ts
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
188
apps/server/src/resource-migration.integration.ts
Normal file
188
apps/server/src/resource-migration.integration.ts
Normal 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 ?? "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -43,8 +43,8 @@ export async function verifyResources(
|
|||||||
return ResourceResponseSchema.parse(await response.json()).resource;
|
return ResourceResponseSchema.parse(await response.json()).resource;
|
||||||
}
|
}
|
||||||
const initial = await read(global);
|
const initial = await read(global);
|
||||||
assert.equal(initial.items.length, 16);
|
assert.equal(initial.items.length, 18);
|
||||||
assert.equal(initial.items.filter((item) => item.values.kind === "template").length, 4);
|
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) => item.builtin && item.id[14] === "7"));
|
||||||
assert.ok(
|
assert.ok(
|
||||||
initial.items.every((item) =>
|
initial.items.every((item) =>
|
||||||
@ -240,7 +240,10 @@ export async function verifyResources(
|
|||||||
templateId: stockTemplate.id,
|
templateId: stockTemplate.id,
|
||||||
active: true,
|
active: true,
|
||||||
notes: "",
|
notes: "",
|
||||||
characteristics: {},
|
characteristics: {
|
||||||
|
[initial.items.find((item) => item.builtinKey === "field:email")?.id ?? ""]:
|
||||||
|
"concurrent@example.test",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const concurrent = await Promise.all([
|
const concurrent = await Promise.all([
|
||||||
request(inventory, { values: concurrentInput }),
|
request(inventory, { values: concurrentInput }),
|
||||||
|
|||||||
@ -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).
|
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.
|
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.
|
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.
|
||||||
|
|||||||
@ -4,7 +4,7 @@ Un buzón identifica una dirección de correo, independiente de la cuenta inform
|
|||||||
|
|
||||||
## Buzones
|
## 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.
|
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
|
## 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.
|
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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
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.
|
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).
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
## Dónde se gestionan
|
## Dónde se gestionan
|
||||||
|
|
||||||
- Catálogos informáticos, en la navegación principal: sistemas, tipos de recurso, permisos disponibles y plantillas de grupos comunes.
|
- 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 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.
|
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
|
## 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.
|
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
|
## 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.
|
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).
|
||||||
|
|||||||
41
docs/impresoras-del-perfil.md
Normal file
41
docs/impresoras-del-perfil.md
Normal 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.
|
||||||
@ -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 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.
|
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.
|
||||||
|
|||||||
@ -49,14 +49,17 @@ Buzones independientes corporativos o externos, cuenta informática opcional, pe
|
|||||||
|
|
||||||
## Recursos con campos y plantillas: base implementada
|
## 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
|
## 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.
|
1. Configuraciones del perfil: aplicaciones y certificados como recursos; después ficha del entorno actual reuniendo correos, carpetas, impresoras y los nuevos recursos.
|
||||||
2. Configuraciones del perfil: carpetas compartidas, impresoras y recursos; después programas y certificados.
|
2. Teletrabajo y accesos remotos: autorización, equipo de origen, conectividad VPN y destino. Asistencia técnica remota independiente.
|
||||||
3. 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.
|
||||||
4. Procesos de alta, cambio y salida con seguimiento de configuraciones y retiradas. Plantillas reutilizables entre compañías; ejecuciones y datos propios.
|
|
||||||
|
|
||||||
Este orden se concreta antes de cada entrega. Las licencias y su relación con cuentas se explorarán posteriormente.
|
Este orden se concreta antes de cada entrega. Las licencias y su relación con cuentas se explorarán posteriormente.
|
||||||
|
|
||||||
|
|||||||
@ -20,7 +20,7 @@ Cada compañía puede activar o desactivar una definición global para su ámbit
|
|||||||
|
|
||||||
## Datos de serie
|
## Datos de serie
|
||||||
|
|
||||||
Se incluyen 12 campos y cuatro plantillas:
|
Se incluyen 13 campos y cinco plantillas:
|
||||||
|
|
||||||
| Plantilla | Campos |
|
| Plantilla | Campos |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@ -28,10 +28,11 @@ Se incluyen 12 campos y cuatro plantillas:
|
|||||||
| Carpeta compartida | Ruta UNC, servidor como referencia documental y descripción |
|
| Carpeta compartida | Ruta UNC, servidor como referencia documental y descripción |
|
||||||
| Aplicación | Proveedor, URL y descripción |
|
| Aplicación | Proveedor, URL y descripción |
|
||||||
| Certificado digital | Titular, emisor, caducidad, huella digital y finalidad |
|
| 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
|
## 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.
|
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
|
## 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.
|
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.
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc -p tsconfig.json",
|
"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": {
|
"dependencies": {
|
||||||
"@repo/rdx-ddd": "workspace:*",
|
"@repo/rdx-ddd": "workspace:*",
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
@ -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
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
213
modules/companies/src/api/domain/printer-configuration.test.ts
Normal file
213
modules/companies/src/api/domain/printer-configuration.test.ts
Normal 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() }))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -50,7 +50,8 @@ function actorId(request: Request) {
|
|||||||
export function registerAccessRoutes(
|
export function registerAccessRoutes(
|
||||||
app: Application,
|
app: Application,
|
||||||
useCases: AccessUseCases,
|
useCases: AccessUseCases,
|
||||||
authenticate: RequestHandler
|
authenticate: RequestHandler,
|
||||||
|
legacyResourceWrites = false
|
||||||
) {
|
) {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
router.get("/:scope", (request, response, next) => {
|
router.get("/:scope", (request, response, next) => {
|
||||||
@ -74,6 +75,10 @@ export function registerAccessRoutes(
|
|||||||
sendError(response, "INVALID_REQUEST", 400);
|
sendError(response, "INVALID_REQUEST", 400);
|
||||||
return;
|
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 id = z.object({ id: z.uuid() }).safeParse(params.data);
|
||||||
const identity = id.success ? id.data.id : undefined;
|
const identity = id.success ? id.data.id : undefined;
|
||||||
useCases
|
useCases
|
||||||
|
|||||||
@ -14,7 +14,8 @@ function fail(response: Response, code: string) {
|
|||||||
export function registerMailboxRoutes(
|
export function registerMailboxRoutes(
|
||||||
app: Application,
|
app: Application,
|
||||||
useCases: MailboxUseCases,
|
useCases: MailboxUseCases,
|
||||||
authenticate: RequestHandler
|
authenticate: RequestHandler,
|
||||||
|
legacyResourceWrites = false
|
||||||
) {
|
) {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
router.get("/:companyId", (request, response, next) => {
|
router.get("/:companyId", (request, response, next) => {
|
||||||
@ -61,6 +62,10 @@ export function registerMailboxRoutes(
|
|||||||
fail(response, "INVALID_REQUEST");
|
fail(response, "INVALID_REQUEST");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!legacyResourceWrites && "address" in body.data.values) {
|
||||||
|
fail(response, "RESOURCE_MOVED");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const id =
|
const id =
|
||||||
"id" in params.data && typeof params.data.id === "string" ? params.data.id : undefined;
|
"id" in params.data && typeof params.data.id === "string" ? params.data.id : undefined;
|
||||||
useCases
|
useCases
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 { RequestHandler } from "express";
|
||||||
import type { Sequelize } from "sequelize";
|
import type { Sequelize } from "sequelize";
|
||||||
|
|
||||||
import { AccessUseCases } from "./application/access.use-cases";
|
import { AccessUseCases } from "./application/access.use-cases";
|
||||||
import { CompanyUseCases } from "./application/company.use-cases";
|
import { CompanyUseCases } from "./application/company.use-cases";
|
||||||
import { CompanyInputMapper } from "./application/company-input.mapper";
|
import { CompanyInputMapper } from "./application/company-input.mapper";
|
||||||
|
import { FolderMappingUseCases } from "./application/folder-mapping.use-cases";
|
||||||
import type { IMailInventoryService } from "./application/mail-configuration.repository";
|
import type { IMailInventoryService } from "./application/mail-configuration.repository";
|
||||||
import { MailConfigurationUseCases } from "./application/mail-configuration.use-cases";
|
import { MailConfigurationUseCases } from "./application/mail-configuration.use-cases";
|
||||||
import { MailboxUseCases } from "./application/mailbox.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 type { IWorkProfileInventoryService } from "./application/work-profile.repository";
|
||||||
import { WorkProfileUseCases } from "./application/work-profile.use-cases";
|
import { WorkProfileUseCases } from "./application/work-profile.use-cases";
|
||||||
import { registerAccessRoutes } from "./infrastructure/http/access.routes";
|
import { registerAccessRoutes } from "./infrastructure/http/access.routes";
|
||||||
import { registerCompanyRoutes } from "./infrastructure/http/company.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 { registerMailRoutes } from "./infrastructure/http/mail-configuration.routes";
|
||||||
import { registerMailboxRoutes } from "./infrastructure/http/mailbox.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 { registerWorkProfileRoutes } from "./infrastructure/http/work-profile.routes";
|
||||||
import {
|
import {
|
||||||
AccessPersistenceMapper,
|
AccessPersistenceMapper,
|
||||||
@ -25,18 +25,26 @@ import {
|
|||||||
} from "./infrastructure/persistence/access-persistence";
|
} from "./infrastructure/persistence/access-persistence";
|
||||||
import { initializeCompanyModels } from "./infrastructure/persistence/company.model";
|
import { initializeCompanyModels } from "./infrastructure/persistence/company.model";
|
||||||
import { CompanyPersistenceMapper } from "./infrastructure/persistence/company-persistence.mapper";
|
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 { migrateMailConfigurations } from "./infrastructure/persistence/mail-configuration-migration";
|
||||||
import { migrateMailboxes } from "./infrastructure/persistence/mailbox-migration";
|
import { migrateMailboxes } from "./infrastructure/persistence/mailbox-migration";
|
||||||
import { migrateCompanies } from "./infrastructure/persistence/migrate-companies";
|
import { migrateCompanies } from "./infrastructure/persistence/migrate-companies";
|
||||||
import { migratePeople } from "./infrastructure/persistence/migrate-people";
|
import { migratePeople } from "./infrastructure/persistence/migrate-people";
|
||||||
import { initializePeopleModels } from "./infrastructure/persistence/people.model";
|
import { initializePeopleModels } from "./infrastructure/persistence/people.model";
|
||||||
|
import { migratePrinterConfigurations } from "./infrastructure/persistence/printer-configuration-migration";
|
||||||
import { SequelizeCompanyRepository } from "./infrastructure/persistence/sequelize-company.repository";
|
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 { SequelizeMailConfigurationRepository } from "./infrastructure/persistence/sequelize-mail-configuration.repository";
|
||||||
import { SequelizeMailboxRepository } from "./infrastructure/persistence/sequelize-mailbox.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 { SequelizeWorkProfileRepository } from "./infrastructure/persistence/sequelize-work-profile.repository";
|
||||||
import { migrateWorkProfiles } from "./infrastructure/persistence/work-profile-migration";
|
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 migrateCompanies(database);
|
||||||
await migratePeople(database);
|
await migratePeople(database);
|
||||||
initializeCompanyModels(database);
|
initializeCompanyModels(database);
|
||||||
@ -51,7 +59,13 @@ export async function createCompanyModule(database: Sequelize, authenticate: Req
|
|||||||
initializeProfiles: async (inventory: IWorkProfileInventoryService) => {
|
initializeProfiles: async (inventory: IWorkProfileInventoryService) => {
|
||||||
await migrateWorkProfiles(database);
|
await migrateWorkProfiles(database);
|
||||||
await migrateFolderMappings(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(
|
const profiles = new WorkProfileUseCases(
|
||||||
new SequelizeWorkProfileRepository(database, inventory)
|
new SequelizeWorkProfileRepository(database, inventory)
|
||||||
);
|
);
|
||||||
@ -59,6 +73,7 @@ export async function createCompanyModule(database: Sequelize, authenticate: Req
|
|||||||
register: (app: Parameters<typeof registerWorkProfileRoutes>[0]) => {
|
register: (app: Parameters<typeof registerWorkProfileRoutes>[0]) => {
|
||||||
registerWorkProfileRoutes(app, profiles, authenticate);
|
registerWorkProfileRoutes(app, profiles, authenticate);
|
||||||
registerFolderMappingRoutes(app, folders, authenticate);
|
registerFolderMappingRoutes(app, folders, authenticate);
|
||||||
|
registerPrinterConfigurationRoutes(app, printers, authenticate);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@ -74,13 +89,13 @@ export async function createCompanyModule(database: Sequelize, authenticate: Req
|
|||||||
return {
|
return {
|
||||||
register: (app: Parameters<typeof registerMailRoutes>[0]) => {
|
register: (app: Parameters<typeof registerMailRoutes>[0]) => {
|
||||||
registerMailRoutes(app, mail, authenticate);
|
registerMailRoutes(app, mail, authenticate);
|
||||||
registerMailboxRoutes(app, mailboxes, authenticate);
|
registerMailboxRoutes(app, mailboxes, authenticate, legacyResourceWrites);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
register: (app: Parameters<typeof registerCompanyRoutes>[0]) => {
|
register: (app: Parameters<typeof registerCompanyRoutes>[0]) => {
|
||||||
registerCompanyRoutes(app, useCases, authenticate);
|
registerCompanyRoutes(app, useCases, authenticate);
|
||||||
registerAccessRoutes(app, access, authenticate);
|
registerAccessRoutes(app, access, authenticate, legacyResourceWrites);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,8 +5,8 @@ export * from "./dto/response/company.response.dto";
|
|||||||
export * from "./dto/shared/access.dto";
|
export * from "./dto/shared/access.dto";
|
||||||
export * from "./dto/shared/company.dto";
|
export * from "./dto/shared/company.dto";
|
||||||
export * from "./dto/shared/people.dto";
|
export * from "./dto/shared/people.dto";
|
||||||
|
export * from "./folder-mappings";
|
||||||
export * from "./mail-configurations";
|
export * from "./mail-configurations";
|
||||||
export * from "./mailboxes";
|
export * from "./mailboxes";
|
||||||
|
export * from "./printer-configurations";
|
||||||
export * from "./work-profiles";
|
export * from "./work-profiles";
|
||||||
|
|
||||||
export * from "./folder-mappings";
|
|
||||||
|
|||||||
38
modules/companies/src/common/printer-configurations.ts
Normal file
38
modules/companies/src/common/printer-configurations.ts
Normal 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>;
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { ResourceEditor } from "@metis/resources/client";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { FormActions, FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
import { FormActions, FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
@ -26,7 +27,8 @@ import { EntityHelp } from "./entity-help";
|
|||||||
import { QuickCreateDialog } from "./quick-create-dialog";
|
import { QuickCreateDialog } from "./quick-create-dialog";
|
||||||
import type { QuickCreatePlan } from "./quick-create-plan";
|
import type { QuickCreatePlan } from "./quick-create-plan";
|
||||||
import { matchesSubmittedValues, quickCreatePlan } from "./quick-create-plan";
|
import { matchesSubmittedValues, quickCreatePlan } from "./quick-create-plan";
|
||||||
export function AccessForm({
|
|
||||||
|
function AccessFormInternal({
|
||||||
scope,
|
scope,
|
||||||
kind,
|
kind,
|
||||||
item,
|
item,
|
||||||
@ -330,3 +332,25 @@ function AccessQuickEditor({
|
|||||||
</FormDialog>
|
</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} />;
|
||||||
|
}
|
||||||
|
|||||||
@ -55,7 +55,6 @@ export function AccessWorkspacePage({
|
|||||||
"account_person",
|
"account_person",
|
||||||
"group",
|
"group",
|
||||||
"membership",
|
"membership",
|
||||||
"resource",
|
|
||||||
"grant",
|
"grant",
|
||||||
"need",
|
"need",
|
||||||
"coverage",
|
"coverage",
|
||||||
|
|||||||
@ -262,14 +262,7 @@ test("los formularios ofrecen altas relacionadas y el segundo modal evita altas
|
|||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</I18nextProvider>
|
</I18nextProvider>
|
||||||
);
|
);
|
||||||
for (const kind of [
|
for (const kind of ["permission", "account_person", "grant", "need", "group"] as const) {
|
||||||
"permission",
|
|
||||||
"account_person",
|
|
||||||
"resource",
|
|
||||||
"grant",
|
|
||||||
"need",
|
|
||||||
"group",
|
|
||||||
] as const) {
|
|
||||||
assert.ok(render(kind).includes('data-slot="button-group"'), kind);
|
assert.ok(render(kind).includes('data-slot="button-group"'), kind);
|
||||||
assert.equal(render(kind, false).includes('data-slot="button-group"'), false, kind);
|
assert.equal(render(kind, false).includes('data-slot="button-group"'), false, kind);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,7 +23,8 @@ import { AccessWorkspacePage } from "../access/access-workspace";
|
|||||||
import { companyApi, companyKeys } from "../api/company-api";
|
import { companyApi, companyKeys } from "../api/company-api";
|
||||||
import { FolderMappingsPage } from "../folders/folder-mappings-page";
|
import { FolderMappingsPage } from "../folders/folder-mappings-page";
|
||||||
import { MailConfigurationsPage } from "../mail/mail-configurations-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 { WorkProfilesPage } from "../profiles/work-profiles-page";
|
||||||
|
|
||||||
import { CompanyFeedback } from "./company-feedback";
|
import { CompanyFeedback } from "./company-feedback";
|
||||||
@ -116,8 +117,8 @@ type CompanyTab =
|
|||||||
| "assets"
|
| "assets"
|
||||||
| "inventory"
|
| "inventory"
|
||||||
| "workstations"
|
| "workstations"
|
||||||
|
| "printers"
|
||||||
| "folders"
|
| "folders"
|
||||||
| "mailboxes"
|
|
||||||
| "mail"
|
| "mail"
|
||||||
| "profiles";
|
| "profiles";
|
||||||
|
|
||||||
@ -220,7 +221,7 @@ export function CompanyDetail({
|
|||||||
["sites", `${t("sites")} (${company.sites.length})`, MapPinIcon],
|
["sites", `${t("sites")} (${company.sites.length})`, MapPinIcon],
|
||||||
["access", accessTranslation("title"), KeyRoundIcon],
|
["access", accessTranslation("title"), KeyRoundIcon],
|
||||||
["folders", t("folders.title"), MailIcon],
|
["folders", t("folders.title"), MailIcon],
|
||||||
["mailboxes", t("mailbox.title"), MailIcon],
|
["printers", t("printers.title"), LayersIcon],
|
||||||
["mail", t("mail.title"), MailIcon],
|
["mail", t("mail.title"), MailIcon],
|
||||||
["assets", assetTranslation("companyTitle"), LayoutTemplateIcon],
|
["assets", assetTranslation("companyTitle"), LayoutTemplateIcon],
|
||||||
["resource-catalog", resourceTranslation("companyTitle"), LayoutTemplateIcon],
|
["resource-catalog", resourceTranslation("companyTitle"), LayoutTemplateIcon],
|
||||||
@ -270,10 +271,10 @@ function CompanySection({
|
|||||||
);
|
);
|
||||||
case "profiles":
|
case "profiles":
|
||||||
return <WorkProfilesPage company={company} csrfToken={csrfToken} />;
|
return <WorkProfilesPage company={company} csrfToken={csrfToken} />;
|
||||||
|
case "printers":
|
||||||
|
return <PrinterConfigurationsPage company={company} csrfToken={csrfToken} />;
|
||||||
case "folders":
|
case "folders":
|
||||||
return <FolderMappingsPage company={company} csrfToken={csrfToken} />;
|
return <FolderMappingsPage company={company} csrfToken={csrfToken} />;
|
||||||
case "mailboxes":
|
|
||||||
return <MailboxesPage company={company} csrfToken={csrfToken} />;
|
|
||||||
case "mail":
|
case "mail":
|
||||||
return <MailConfigurationsPage company={company} csrfToken={csrfToken} />;
|
return <MailConfigurationsPage company={company} csrfToken={csrfToken} />;
|
||||||
case "workstations":
|
case "workstations":
|
||||||
@ -281,7 +282,17 @@ function CompanySection({
|
|||||||
case "inventory":
|
case "inventory":
|
||||||
return <CompanyInventory company={company} csrfToken={csrfToken} />;
|
return <CompanyInventory company={company} csrfToken={csrfToken} />;
|
||||||
case "resources":
|
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":
|
case "resource-catalog":
|
||||||
return <ResourceCatalogPage csrfToken={csrfToken} scope={company.id} />;
|
return <ResourceCatalogPage csrfToken={csrfToken} scope={company.id} />;
|
||||||
case "assets":
|
case "assets":
|
||||||
|
|||||||
@ -18,6 +18,7 @@ const titles = {
|
|||||||
} as const;
|
} as const;
|
||||||
export function ConfigurationQuickEditor({
|
export function ConfigurationQuickEditor({
|
||||||
kind,
|
kind,
|
||||||
|
title,
|
||||||
company,
|
company,
|
||||||
csrfToken,
|
csrfToken,
|
||||||
values,
|
values,
|
||||||
@ -26,6 +27,7 @@ export function ConfigurationQuickEditor({
|
|||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
kind: ConfigurationQuickKind;
|
kind: ConfigurationQuickKind;
|
||||||
|
title?: string;
|
||||||
company: CompanyDTO;
|
company: CompanyDTO;
|
||||||
csrfToken: string;
|
csrfToken: string;
|
||||||
values: Readonly<{ personId: string; accountId: string }>;
|
values: Readonly<{ personId: string; accountId: string }>;
|
||||||
@ -65,7 +67,7 @@ export function ConfigurationQuickEditor({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
open
|
open
|
||||||
title={t(`mail.${titles[kind]}`)}
|
title={title ?? t(`mail.${titles[kind]}`)}
|
||||||
wide
|
wide
|
||||||
>
|
>
|
||||||
{kind === "person" && (
|
{kind === "person" && (
|
||||||
|
|||||||
@ -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 { mailEnglish, mailSpanish } from "./mail/mail-i18n";
|
||||||
import { mailboxEnglish, mailboxSpanish } from "./mail/mailbox-i18n";
|
import { mailboxEnglish, mailboxSpanish } from "./mail/mailbox-i18n";
|
||||||
|
import { printersEnglish, printersSpanish } from "./printers/printer-i18n";
|
||||||
import { profilesEnglish, profilesSpanish } from "./profiles/profile-i18n";
|
import { profilesEnglish, profilesSpanish } from "./profiles/profile-i18n";
|
||||||
|
|
||||||
export const companiesSpanish = {
|
export const companiesSpanish = {
|
||||||
|
printers: printersSpanish,
|
||||||
mail: mailSpanish,
|
mail: mailSpanish,
|
||||||
mailbox: mailboxSpanish,
|
mailbox: mailboxSpanish,
|
||||||
folders:foldersSpanish,
|
folders: foldersSpanish,
|
||||||
profiles: profilesSpanish,
|
profiles: profilesSpanish,
|
||||||
companySpaces: "Espacios de compañías",
|
companySpaces: "Espacios de compañías",
|
||||||
globalCatalogs: "Catálogos globales",
|
globalCatalogs: "Catálogos globales",
|
||||||
@ -122,9 +124,10 @@ export const companiesSpanish = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
export const companiesEnglish = {
|
export const companiesEnglish = {
|
||||||
|
printers: printersEnglish,
|
||||||
mail: mailEnglish,
|
mail: mailEnglish,
|
||||||
mailbox: mailboxEnglish,
|
mailbox: mailboxEnglish,
|
||||||
folders:foldersEnglish,
|
folders: foldersEnglish,
|
||||||
profiles: profilesEnglish,
|
profiles: profilesEnglish,
|
||||||
companySpaces: "Company spaces",
|
companySpaces: "Company spaces",
|
||||||
globalCatalogs: "Global catalogs",
|
globalCatalogs: "Global catalogs",
|
||||||
|
|||||||
@ -2,6 +2,12 @@ import assert from "node:assert/strict";
|
|||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
|
|
||||||
import { inventoryKeys } from "@metis/assets/client";
|
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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { createInstance } from "i18next";
|
import { createInstance } from "i18next";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
@ -17,7 +23,7 @@ import { MailConfigurationForm } from "./mail-configuration-form";
|
|||||||
import { MailConfigurationsPage } from "./mail-configurations-page";
|
import { MailConfigurationsPage } from "./mail-configurations-page";
|
||||||
import { mailboxKeys } from "./mailbox-api";
|
import { mailboxKeys } from "./mailbox-api";
|
||||||
import { MailboxForm } from "./mailbox-form";
|
import { MailboxForm } from "./mailbox-form";
|
||||||
import { MailboxesPage } from "./mailboxes-page";
|
import { MailboxRelations } from "./mailbox-relations";
|
||||||
|
|
||||||
const company = {
|
const company = {
|
||||||
id: "0199b200-0000-7000-8000-000000000001",
|
id: "0199b200-0000-7000-8000-000000000001",
|
||||||
@ -33,11 +39,47 @@ async function render(language: string, node: ReactNode) {
|
|||||||
const i18n = createInstance();
|
const i18n = createInstance();
|
||||||
await i18n.init({
|
await i18n.init({
|
||||||
lng: language,
|
lng: language,
|
||||||
resources: { es: { companies: companiesSpanish }, en: { companies: companiesEnglish } },
|
resources: {
|
||||||
|
es: { companies: companiesSpanish, resources: resourcesSpanish },
|
||||||
|
en: { companies: companiesEnglish, resources: resourcesEnglish },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const client = new QueryClient({
|
const client = new QueryClient({
|
||||||
defaultOptions: { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY } },
|
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(mailKeys.company(company.id), { configurations: [] });
|
||||||
client.setQueryData(profileKeys.company(company.id), { profiles: [] });
|
client.setQueryData(profileKeys.company(company.id), { profiles: [] });
|
||||||
client.setQueryData(mailboxKeys.company(company.id), { mailboxes: [], uses: [] });
|
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 () => {
|
test("Buzones: alta en modal, cuenta opcional y ayuda al final", async () => {
|
||||||
for (const language of ["es", "en"]) {
|
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.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(
|
const form = await render(
|
||||||
"es",
|
"es",
|
||||||
<MailboxForm company={company} csrfToken="test" onSaved={() => undefined} />
|
<MailboxForm company={company} csrfToken="test" onSaved={() => undefined} />
|
||||||
);
|
);
|
||||||
assert.ok(form.includes("Cuenta informática relacionada (opcional)"));
|
assert.ok(form.includes("Cuenta relacionada (opcional)"));
|
||||||
assert.ok(form.indexOf("¿Qué es un buzón de correo?") < form.indexOf("Guardar"));
|
assert.ok(form.indexOf("¿Qué es y para qué sirve?") < form.indexOf("Guardar"));
|
||||||
assert.equal(form.includes('type="password"'), false);
|
assert.equal(form.includes('type="password"'), false);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,26 +1,8 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { ResourceEditor } from "@metis/resources/client";
|
||||||
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 type { CompanyDTO } from "../../common";
|
import type { CompanyDTO } from "../../common";
|
||||||
import { type MailboxDTO, type MailboxInput, MailboxInputSchema } from "../../common/mailboxes";
|
import type { MailboxDTO } from "../../common/mailboxes";
|
||||||
import { accessApi, accessKeys } from "../access/access-api";
|
/** El alta rápida de correo utiliza la misma ficha y persistencia que Recursos. */
|
||||||
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";
|
|
||||||
|
|
||||||
export function MailboxForm({
|
export function MailboxForm({
|
||||||
company,
|
company,
|
||||||
csrfToken,
|
csrfToken,
|
||||||
@ -36,158 +18,15 @@ export function MailboxForm({
|
|||||||
allowQuickCreate?: boolean;
|
allowQuickCreate?: boolean;
|
||||||
initialAddress?: string;
|
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 (
|
return (
|
||||||
<>
|
<ResourceEditor
|
||||||
<form className="space-y-5" onSubmit={form.handleSubmit((input) => mutation.mutate(input))}>
|
allowQuickCreate={allowQuickCreate}
|
||||||
<fieldset className="space-y-4" disabled={mutation.isPending}>
|
companyId={company.id}
|
||||||
<div className="space-y-2">
|
csrfToken={csrfToken}
|
||||||
<Label htmlFor={prefix}>{t("mail.address")}</Label>
|
initialAddress={initialAddress}
|
||||||
<Input
|
initialKind="mailbox"
|
||||||
id={prefix}
|
onSaved={onSaved}
|
||||||
maxLength={254}
|
resourceId={mailbox?.id}
|
||||||
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: "" }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
78
modules/companies/src/web/mail/mailbox-relations.tsx
Normal file
78
modules/companies/src/web/mail/mailbox-relations.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
43
modules/companies/src/web/printers/printer-api.ts
Normal file
43
modules/companies/src/web/printers/printer-api.ts
Normal 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));
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -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"];
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
modules/companies/src/web/printers/printer-feedback.tsx
Normal file
11
modules/companies/src/web/printers/printer-feedback.tsx
Normal 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;
|
||||||
|
}
|
||||||
86
modules/companies/src/web/printers/printer-i18n.ts
Normal file
86
modules/companies/src/web/printers/printer-i18n.ts
Normal 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.",
|
||||||
|
},
|
||||||
|
};
|
||||||
83
modules/companies/src/web/printers/printer.test.tsx
Normal file
83
modules/companies/src/web/printers/printer.test.tsx
Normal 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."));
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -1,4 +1,3 @@
|
|||||||
import {FolderMappingsPage} from "../folders/folder-mappings-page";
|
|
||||||
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
|
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
|
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
|
||||||
@ -11,7 +10,9 @@ import { useTranslation } from "react-i18next";
|
|||||||
import type { CompanyDTO } from "../../common";
|
import type { CompanyDTO } from "../../common";
|
||||||
import { accessApi, accessKeys } from "../access/access-api";
|
import { accessApi, accessKeys } from "../access/access-api";
|
||||||
import { accessLabel } from "../access/access-labels";
|
import { accessLabel } from "../access/access-labels";
|
||||||
|
import { FolderMappingsPage } from "../folders/folder-mappings-page";
|
||||||
import { MailConfigurationsPage } from "../mail/mail-configurations-page";
|
import { MailConfigurationsPage } from "../mail/mail-configurations-page";
|
||||||
|
import { PrinterConfigurationsPage } from "../printers/printer-configurations-page";
|
||||||
|
|
||||||
import { ProfileFeedback } from "./profile-feedback";
|
import { ProfileFeedback } from "./profile-feedback";
|
||||||
import { profileApi, profileKeys } from "./work-profile-api";
|
import { profileApi, profileKeys } from "./work-profile-api";
|
||||||
@ -25,8 +26,10 @@ export function WorkProfilesPage({
|
|||||||
csrfToken: string;
|
csrfToken: string;
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation("companies");
|
const { t, i18n } = useTranslation("companies");
|
||||||
const [folderProfile,setFolderProfile]=useState<string>();
|
const [configuration, setConfiguration] = useState<{
|
||||||
const [mailProfile, setMailProfile] = useState<string>();
|
kind: "folders" | "mail" | "printers";
|
||||||
|
profileId: string;
|
||||||
|
}>();
|
||||||
const [editor, setEditor] = useState<string>();
|
const [editor, setEditor] = useState<string>();
|
||||||
const [view, setView] = useState<string>();
|
const [view, setView] = useState<string>();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
@ -63,20 +66,14 @@ export function WorkProfilesPage({
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
const ready = [query.data, inventory.data, access.data].every(Boolean);
|
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>
|
if (configuration) {
|
||||||
<FolderMappingsPage company={company} csrfToken={csrfToken} initialProfileId={folderProfile} /></div>;}
|
|
||||||
if (mailProfile) {
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<ProfileConfigurations
|
||||||
<Button onClick={() => setMailProfile(undefined)} variant="outline">
|
company={company}
|
||||||
{t("profiles.title")}
|
csrfToken={csrfToken}
|
||||||
</Button>
|
onBack={() => setConfiguration(undefined)}
|
||||||
<MailConfigurationsPage
|
selection={configuration}
|
||||||
company={company}
|
/>
|
||||||
csrfToken={csrfToken}
|
|
||||||
initialProfileId={mailProfile}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
@ -141,8 +138,25 @@ export function WorkProfilesPage({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button onClick={()=>setFolderProfile(item.id)} size="sm" variant="outline">{t("folders.title")}</Button>
|
<Button
|
||||||
<Button onClick={() => setMailProfile(item.id)} size="sm" variant="outline">
|
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")}
|
{t("mail.title")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => setView(item.id)} size="sm" variant="outline">
|
<Button onClick={() => setView(item.id)} size="sm" variant="outline">
|
||||||
@ -241,3 +255,28 @@ export function WorkProfilesPage({
|
|||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -12,6 +12,7 @@ export function buildCatalogResponse(
|
|||||||
id: item.id.toPrimitive(),
|
id: item.id.toPrimitive(),
|
||||||
companyId: item.companyId.getOrUndefined()?.toPrimitive() ?? null,
|
companyId: item.companyId.getOrUndefined()?.toPrimitive() ?? null,
|
||||||
builtin: item.stockKey.getOrUndefined() !== undefined,
|
builtin: item.stockKey.getOrUndefined() !== undefined,
|
||||||
|
builtinKey: item.stockKey.getOrUndefined() ?? null,
|
||||||
active: item.active,
|
active: item.active,
|
||||||
effectiveActive: catalog.active(item, scope),
|
effectiveActive: catalog.active(item, scope),
|
||||||
overridden: catalog.availability.some(
|
overridden: catalog.availability.some(
|
||||||
|
|||||||
@ -17,44 +17,126 @@ export function mapResourceInput(
|
|||||||
if (previous && previous.values.toPrimitive().templateId !== input.templateId.toLowerCase()) {
|
if (previous && previous.values.toPrimitive().templateId !== input.templateId.toLowerCase()) {
|
||||||
return Result.fail(new ResourceCatalogError("TEMPLATE_FIXED"));
|
return Result.fail(new ResourceCatalogError("TEMPLATE_FIXED"));
|
||||||
}
|
}
|
||||||
let specification = previous?.specification;
|
const captured = previous
|
||||||
if (!specification) {
|
? Result.ok(previous.specification)
|
||||||
const template = context.catalog
|
: captureSpecification(input, companyId, context);
|
||||||
.visible(Maybe.some(companyId))
|
if (!captured.isSuccess) {
|
||||||
.find((item) => item.id.toPrimitive() === input.templateId.toLowerCase());
|
return captured;
|
||||||
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 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;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import type { UniqueID } from "@repo/rdx-ddd";
|
import type { UniqueID } from "@repo/rdx-ddd";
|
||||||
import type { Result } from "@repo/rdx-utils";
|
import type { Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
|
import type { ResourceReferenceDTO } from "../../common/inventory";
|
||||||
import type { Resource } from "../domain/resource.aggregate";
|
import type { Resource } from "../domain/resource.aggregate";
|
||||||
import type { ResourceCatalog } from "../domain/resource-catalog.aggregate";
|
import type { ResourceCatalog } from "../domain/resource-catalog.aggregate";
|
||||||
import type { ResourceCatalogError } from "../domain/resource-definition";
|
import type { ResourceCatalogError } from "../domain/resource-definition";
|
||||||
@ -9,6 +10,7 @@ export type InventoryContext = Readonly<{
|
|||||||
catalog: ResourceCatalog;
|
catalog: ResourceCatalog;
|
||||||
resources: readonly Resource[];
|
resources: readonly Resource[];
|
||||||
actorNames: ReadonlyMap<string, string>;
|
actorNames: ReadonlyMap<string, string>;
|
||||||
|
references: readonly ResourceReferenceDTO[];
|
||||||
}>;
|
}>;
|
||||||
export interface IResourceRepository {
|
export interface IResourceRepository {
|
||||||
get(companyId: UniqueID): Promise<Result<InventoryContext, ResourceCatalogError>>;
|
get(companyId: UniqueID): Promise<Result<InventoryContext, ResourceCatalogError>>;
|
||||||
|
|||||||
@ -34,11 +34,29 @@ export class ResourceUseCases {
|
|||||||
if (!mapped.isSuccess) {
|
if (!mapped.isSuccess) {
|
||||||
return Result.fail(mapped.error);
|
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 (
|
if (
|
||||||
context.resources.some(
|
context.resources.some(
|
||||||
(item) =>
|
(item) =>
|
||||||
!item.id.equals(previous?.id) &&
|
!item.id.equals(previous?.id) &&
|
||||||
item.values.toPrimitive().templateId === mapped.data.values.toPrimitive().templateId &&
|
item.values.toPrimitive().templateId === mapped.data.values.toPrimitive().templateId &&
|
||||||
|
sameAccessContext(item.values.toPrimitive().integration, current.integration) &&
|
||||||
item.values.name.toPrimitive().toLocaleLowerCase("es") ===
|
item.values.name.toPrimitive().toLocaleLowerCase("es") ===
|
||||||
mapped.data.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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -15,9 +15,11 @@ const FIELDS: readonly [string, string, ResourceFieldType][] = [
|
|||||||
["expires", "Caducidad", "date"],
|
["expires", "Caducidad", "date"],
|
||||||
["fingerprint", "Huella digital", "text"],
|
["fingerprint", "Huella digital", "text"],
|
||||||
["purpose", "Finalidad", "text"],
|
["purpose", "Finalidad", "text"],
|
||||||
|
["reference", "Referencia", "text"],
|
||||||
];
|
];
|
||||||
const TEMPLATES: readonly [string, string, readonly string[]][] = [
|
const TEMPLATES: readonly [string, string, readonly string[]][] = [
|
||||||
["mailbox", "Buzón de correo", ["email", "provider", "context"]],
|
["mailbox", "Buzón de correo", ["email", "provider", "context"]],
|
||||||
|
["access", "Recurso de acceso", ["reference"]],
|
||||||
["shared_folder", "Carpeta compartida", ["unc", "server", "description"]],
|
["shared_folder", "Carpeta compartida", ["unc", "server", "description"]],
|
||||||
["application", "Aplicación", ["provider", "url", "description"]],
|
["application", "Aplicación", ["provider", "url", "description"]],
|
||||||
["certificate", "Certificado digital", ["holder", "issuer", "expires", "fingerprint", "purpose"]],
|
["certificate", "Certificado digital", ["holder", "issuer", "expires", "fingerprint", "purpose"]],
|
||||||
|
|||||||
@ -24,7 +24,7 @@ const field = {
|
|||||||
} as const;
|
} as const;
|
||||||
test("catálogo de recursos: serie idempotente, UUID v7, características opcionales y protección", () => {
|
test("catálogo de recursos: serie idempotente, UUID v7, características opcionales y protección", () => {
|
||||||
const catalog = seedResourceCatalog(ResourceCatalog.create());
|
const catalog = seedResourceCatalog(ResourceCatalog.create());
|
||||||
assert.equal(catalog.definitions.length, 16);
|
assert.equal(catalog.definitions.length, 18);
|
||||||
assert.equal(seedResourceCatalog(catalog), catalog);
|
assert.equal(seedResourceCatalog(catalog), catalog);
|
||||||
assert.ok(catalog.definitions.every((item) => item.id.toPrimitive()[14] === "7"));
|
assert.ok(catalog.definitions.every((item) => item.id.toPrimitive()[14] === "7"));
|
||||||
const template = catalog.definitions.find((item) => item.values.data.kind === "template");
|
const template = catalog.definitions.find((item) => item.values.data.kind === "template");
|
||||||
|
|||||||
@ -15,12 +15,30 @@ export const ResourceValueSchema = z.union([
|
|||||||
z.number().finite(),
|
z.number().finite(),
|
||||||
z.boolean(),
|
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({
|
export const ResourceInputSchema = z.strictObject({
|
||||||
name: z.string().min(1).max(100),
|
name: z.string().min(1).max(100),
|
||||||
templateId: z.uuid(),
|
templateId: z.uuid(),
|
||||||
active: z.boolean(),
|
active: z.boolean(),
|
||||||
notes: z.string().max(10000),
|
notes: z.string().max(10000),
|
||||||
characteristics: z.record(z.uuid(), ResourceValueSchema),
|
characteristics: z.record(z.uuid(), ResourceValueSchema),
|
||||||
|
integration: ResourceIntegrationSchema.optional(),
|
||||||
});
|
});
|
||||||
export const ResourceSpecificationSchema = z.strictObject({
|
export const ResourceSpecificationSchema = z.strictObject({
|
||||||
templateId: z.uuid(),
|
templateId: z.uuid(),
|
||||||
|
|||||||
@ -54,6 +54,24 @@ export class Resource extends AggregateRoot<ResourceProps> {
|
|||||||
if (values.toPrimitive().templateId !== this.values.toPrimitive().templateId) {
|
if (values.toPrimitive().templateId !== this.values.toPrimitive().templateId) {
|
||||||
return Result.fail(new ResourceCatalogError("TEMPLATE_FIXED"));
|
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(
|
return Result.ok(
|
||||||
this.values.equals(values)
|
this.values.equals(values)
|
||||||
? this
|
? this
|
||||||
|
|||||||
@ -106,3 +106,76 @@ test("recurso: identidad estable, historial, desactivación e idempotencia", ()
|
|||||||
assert.ok(!changeTemplate.isSuccess);
|
assert.ok(!changeTemplate.isSuccess);
|
||||||
assert.equal(changeTemplate.error.code, "TEMPLATE_FIXED");
|
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");
|
||||||
|
});
|
||||||
|
|||||||
@ -122,6 +122,11 @@ export class ResourceValues {
|
|||||||
return parsedCharacteristics;
|
return parsedCharacteristics;
|
||||||
}
|
}
|
||||||
const characteristics = parsedCharacteristics.data;
|
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(
|
return Result.ok(
|
||||||
new ResourceValues(
|
new ResourceValues(
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
@ -130,6 +135,7 @@ export class ResourceValues {
|
|||||||
templateId: data.templateId.toLowerCase(),
|
templateId: data.templateId.toLowerCase(),
|
||||||
notes: data.notes.trim(),
|
notes: data.notes.trim(),
|
||||||
characteristics: Object.freeze(characteristics),
|
characteristics: Object.freeze(characteristics),
|
||||||
|
...(integration ? { integration: Object.freeze({ ...integration }) } : {}),
|
||||||
}),
|
}),
|
||||||
name.data
|
name.data
|
||||||
)
|
)
|
||||||
@ -146,6 +152,7 @@ export class ResourceValues {
|
|||||||
first.templateId === second.templateId &&
|
first.templateId === second.templateId &&
|
||||||
first.active === second.active &&
|
first.active === second.active &&
|
||||||
first.notes === second.notes &&
|
first.notes === second.notes &&
|
||||||
|
sameIntegration(first.integration, second.integration) &&
|
||||||
Object.keys(first.characteristics).length === Object.keys(second.characteristics).length &&
|
Object.keys(first.characteristics).length === Object.keys(second.characteristics).length &&
|
||||||
Object.entries(first.characteristics).every(
|
Object.entries(first.characteristics).every(
|
||||||
([key, value]) => value === second.characteristics[key]
|
([key, value]) => value === second.characteristics[key]
|
||||||
@ -180,3 +187,82 @@ function normalizeCharacteristics(
|
|||||||
}
|
}
|
||||||
return Result.ok(characteristics);
|
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")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -35,6 +35,7 @@ export function registerResourceRoutes(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
response.json({
|
response.json({
|
||||||
|
references: result.data.references,
|
||||||
resources: result.data.resources.map((resource) =>
|
resources: result.data.resources.map((resource) =>
|
||||||
buildResourceResponse(resource, result.data.actorNames)
|
buildResourceResponse(resource, result.data.actorNames)
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -2,7 +2,9 @@ import type { UniqueID } from "@repo/rdx-ddd";
|
|||||||
import { Maybe, Result } from "@repo/rdx-utils";
|
import { Maybe, Result } from "@repo/rdx-utils";
|
||||||
import type { Sequelize, Transaction } from "sequelize";
|
import type { Sequelize, Transaction } from "sequelize";
|
||||||
import { QueryTypes } 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 type { IResourceRepository, InventoryContext } from "../../application/resource.repository";
|
||||||
import { ResourceCatalogError } from "../../domain/resource-definition";
|
import { ResourceCatalogError } from "../../domain/resource-definition";
|
||||||
|
|
||||||
@ -14,6 +16,7 @@ import {
|
|||||||
ResourceDefinitionModel,
|
ResourceDefinitionModel,
|
||||||
} from "./resource-catalog.model";
|
} from "./resource-catalog.model";
|
||||||
import { ResourceCatalogPersistenceMapper } from "./resource-catalog-persistence.mapper";
|
import { ResourceCatalogPersistenceMapper } from "./resource-catalog-persistence.mapper";
|
||||||
|
import { projectResource } from "./resource-compatibility";
|
||||||
import { ResourcePersistenceMapper } from "./resource-persistence.mapper";
|
import { ResourcePersistenceMapper } from "./resource-persistence.mapper";
|
||||||
|
|
||||||
export class SequelizeResourceRepository implements IResourceRepository {
|
export class SequelizeResourceRepository implements IResourceRepository {
|
||||||
@ -100,7 +103,30 @@ export class SequelizeResourceRepository implements IResourceRepository {
|
|||||||
"SELECT id, name FROM users",
|
"SELECT id, name FROM users",
|
||||||
{ type: QueryTypes.SELECT, transaction }
|
{ 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({
|
return Result.ok({
|
||||||
|
references,
|
||||||
catalog,
|
catalog,
|
||||||
resources,
|
resources,
|
||||||
actorNames: new Map(actors.map((actor) => [actor.id, actor.name])),
|
actorNames: new Map(actors.map((actor) => [actor.id, actor.name])),
|
||||||
@ -120,6 +146,7 @@ export class SequelizeResourceRepository implements IResourceRepository {
|
|||||||
return changed;
|
return changed;
|
||||||
}
|
}
|
||||||
const persistence = this.mapper.toPersistence(changed.data);
|
const persistence = this.mapper.toPersistence(changed.data);
|
||||||
|
await projectResource(this.database, changed.data, transaction);
|
||||||
await ResourceModel.upsert(persistence.resource, { transaction });
|
await ResourceModel.upsert(persistence.resource, { transaction });
|
||||||
const existing = await ResourceRevisionModel.findAll({
|
const existing = await ResourceRevisionModel.findAll({
|
||||||
where: { resourceId: persistence.resource.id },
|
where: { resourceId: persistence.resource.id },
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { ResourceCatalogUseCases } from "./application/resource-catalog.use-case
|
|||||||
import { seedResourceCatalog } from "./domain/resource-catalog-seed";
|
import { seedResourceCatalog } from "./domain/resource-catalog-seed";
|
||||||
import { registerResourceRoutes } from "./infrastructure/http/resource.routes";
|
import { registerResourceRoutes } from "./infrastructure/http/resource.routes";
|
||||||
import { registerResourceCatalogRoutes } from "./infrastructure/http/resource-catalog.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 { migrateInventory } from "./infrastructure/persistence/resource.model";
|
||||||
import {
|
import {
|
||||||
ResourceCatalogModel,
|
ResourceCatalogModel,
|
||||||
@ -37,6 +38,7 @@ export async function createResourceModule(database: Sequelize, authenticate: Re
|
|||||||
const catalog = new ResourceCatalogUseCases(repository);
|
const catalog = new ResourceCatalogUseCases(repository);
|
||||||
const inventory = new ResourceUseCases(new SequelizeResourceRepository(database));
|
const inventory = new ResourceUseCases(new SequelizeResourceRepository(database));
|
||||||
return {
|
return {
|
||||||
|
migrateLegacy: () => migrateLegacyResources(database),
|
||||||
register: (app: Parameters<typeof registerResourceCatalogRoutes>[0]) => {
|
register: (app: Parameters<typeof registerResourceCatalogRoutes>[0]) => {
|
||||||
registerResourceCatalogRoutes(app, catalog, authenticate);
|
registerResourceCatalogRoutes(app, catalog, authenticate);
|
||||||
registerResourceRoutes(app, inventory, authenticate);
|
registerResourceRoutes(app, inventory, authenticate);
|
||||||
|
|||||||
@ -15,6 +15,7 @@ export const CatalogItemSchema = z.strictObject({
|
|||||||
id: z.uuid(),
|
id: z.uuid(),
|
||||||
companyId: z.uuid().nullable(),
|
companyId: z.uuid().nullable(),
|
||||||
builtin: z.boolean(),
|
builtin: z.boolean(),
|
||||||
|
builtinKey: z.string().nullable().optional(),
|
||||||
active: z.boolean(),
|
active: z.boolean(),
|
||||||
effectiveActive: z.boolean(),
|
effectiveActive: z.boolean(),
|
||||||
overridden: z.boolean(),
|
overridden: z.boolean(),
|
||||||
|
|||||||
@ -26,8 +26,17 @@ export const ResourceSchema = z.strictObject({
|
|||||||
values: ResourceInputSchema,
|
values: ResourceInputSchema,
|
||||||
history: z.array(ResourceRevisionSchema).min(1),
|
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({
|
export const InventoryResponseSchema = z.strictObject({
|
||||||
resources: z.array(ResourceSchema),
|
resources: z.array(ResourceSchema),
|
||||||
|
references: z.array(ResourceReferenceSchema).optional(),
|
||||||
});
|
});
|
||||||
export const ResourceResponseSchema = z.strictObject({ resource: ResourceSchema });
|
export const ResourceResponseSchema = z.strictObject({ resource: ResourceSchema });
|
||||||
export type ResourceDTO = z.infer<typeof ResourceSchema>;
|
export type ResourceDTO = z.infer<typeof ResourceSchema>;
|
||||||
|
|||||||
@ -1,3 +1,6 @@
|
|||||||
|
export { catalogKeys } from "./catalog-api";
|
||||||
export { ResourceCatalogPage } from "./catalog-page";
|
export { ResourceCatalogPage } from "./catalog-page";
|
||||||
export { resourcesEnglish, resourcesSpanish } from "./i18n";
|
export { resourcesEnglish, resourcesSpanish } from "./i18n";
|
||||||
|
export { inventoryKeys } from "./inventory-api";
|
||||||
|
export { ResourceEditor } from "./resource-editor";
|
||||||
export { ResourcesPage } from "./resources-page";
|
export { ResourcesPage } from "./resources-page";
|
||||||
|
|||||||
@ -1,5 +1,12 @@
|
|||||||
export const inventorySpanish = {
|
export const inventorySpanish = {
|
||||||
inventory: "Recursos",
|
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",
|
newResource: "Nuevo recurso",
|
||||||
editResource: "Editar recurso",
|
editResource: "Editar recurso",
|
||||||
viewResource: "Ficha e historial",
|
viewResource: "Ficha e historial",
|
||||||
@ -26,6 +33,9 @@ export const inventorySpanish = {
|
|||||||
relatedNotice: "La plantilla se guardará aunque después canceles el recurso.",
|
relatedNotice: "La plantilla se guardará aunque después canceles el recurso.",
|
||||||
inventoryErrors: {
|
inventoryErrors: {
|
||||||
INVALID_REQUEST: "Revisa los datos del formulario.",
|
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:
|
INVALID_CHARACTERISTICS:
|
||||||
"Una característica no pertenece a la plantilla o tiene un valor inválido.",
|
"Una característica no pertenece a la plantilla o tiene un valor inválido.",
|
||||||
REQUIRED_CHARACTERISTICS: "Completa las características obligatorias de la plantilla.",
|
REQUIRED_CHARACTERISTICS: "Completa las características obligatorias de la plantilla.",
|
||||||
@ -41,6 +51,13 @@ export const inventorySpanish = {
|
|||||||
};
|
};
|
||||||
export const inventoryEnglish = {
|
export const inventoryEnglish = {
|
||||||
inventory: "Resources",
|
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",
|
newResource: "New resource",
|
||||||
editResource: "Edit resource",
|
editResource: "Edit resource",
|
||||||
viewResource: "Details and history",
|
viewResource: "Details and history",
|
||||||
@ -66,6 +83,9 @@ export const inventoryEnglish = {
|
|||||||
relatedNotice: "The template remains saved if you later cancel the resource.",
|
relatedNotice: "The template remains saved if you later cancel the resource.",
|
||||||
inventoryErrors: {
|
inventoryErrors: {
|
||||||
INVALID_REQUEST: "Check the form data.",
|
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:
|
INVALID_CHARACTERISTICS:
|
||||||
"A characteristic is not part of the template or has an invalid value.",
|
"A characteristic is not part of the template or has an invalid value.",
|
||||||
REQUIRED_CHARACTERISTICS: "Complete the required template characteristics.",
|
REQUIRED_CHARACTERISTICS: "Complete the required template characteristics.",
|
||||||
|
|||||||
@ -1,8 +1,14 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
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 { t, i18n } = useTranslation("resources");
|
||||||
const display = (value: string | number | boolean | undefined) =>
|
const display = (value: string | number | boolean | undefined) =>
|
||||||
value === undefined
|
value === undefined
|
||||||
@ -34,6 +40,7 @@ export function ResourceDetail({ resource }: { resource: ResourceDTO }) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
|
<IntegrationSummary integration={resource.values.integration} references={references} />
|
||||||
{resource.values.notes && (
|
{resource.values.notes && (
|
||||||
<p className="whitespace-pre-wrap break-words">{resource.values.notes}</p>
|
<p className="whitespace-pre-wrap break-words">{resource.values.notes}</p>
|
||||||
)}
|
)}
|
||||||
@ -63,6 +70,7 @@ export function ResourceDetail({ resource }: { resource: ResourceDTO }) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
|
<IntegrationSummary integration={revision.values.integration} references={references} />
|
||||||
{revision.values.notes && (
|
{revision.values.notes && (
|
||||||
<p className="whitespace-pre-wrap break-words">{revision.values.notes}</p>
|
<p className="whitespace-pre-wrap break-words">{revision.values.notes}</p>
|
||||||
)}
|
)}
|
||||||
@ -72,3 +80,46 @@ export function ResourceDetail({ resource }: { resource: ResourceDTO }) {
|
|||||||
</div>
|
</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;
|
||||||
|
}
|
||||||
|
|||||||
70
modules/resources/src/web/resource-editor.tsx
Normal file
70
modules/resources/src/web/resource-editor.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -6,7 +6,7 @@ import { Input } from "@repo/shadcn-ui/components/input";
|
|||||||
import { Label } from "@repo/shadcn-ui/components/label";
|
import { Label } from "@repo/shadcn-ui/components/label";
|
||||||
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
||||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
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 { useEffect, useId, useRef, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -18,6 +18,7 @@ import { ResourceInputSchema } from "../common/inventory";
|
|||||||
import { CatalogTemplateForm } from "./catalog-template-form";
|
import { CatalogTemplateForm } from "./catalog-template-form";
|
||||||
import { InventoryApiError, inventoryApi, inventoryKeys } from "./inventory-api";
|
import { InventoryApiError, inventoryApi, inventoryKeys } from "./inventory-api";
|
||||||
import { ResourceCharacteristicControl } from "./resource-characteristic-control";
|
import { ResourceCharacteristicControl } from "./resource-characteristic-control";
|
||||||
|
import { ResourceIntegrationFields, mailboxIntegration } from "./resource-integration-fields";
|
||||||
export function InventoryFeedback({ error }: { error: Error | null }) {
|
export function InventoryFeedback({ error }: { error: Error | null }) {
|
||||||
const { t } = useTranslation("resources");
|
const { t } = useTranslation("resources");
|
||||||
return error ? (
|
return error ? (
|
||||||
@ -33,6 +34,9 @@ export function ResourceForm({
|
|||||||
items,
|
items,
|
||||||
onSaved,
|
onSaved,
|
||||||
allowQuickCreate = true,
|
allowQuickCreate = true,
|
||||||
|
initialKind,
|
||||||
|
initialAddress = "",
|
||||||
|
initialIntegration,
|
||||||
}: {
|
}: {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
csrfToken: string;
|
csrfToken: string;
|
||||||
@ -40,6 +44,9 @@ export function ResourceForm({
|
|||||||
items: readonly CatalogItemDTO[];
|
items: readonly CatalogItemDTO[];
|
||||||
onSaved: (id: string) => void;
|
onSaved: (id: string) => void;
|
||||||
allowQuickCreate?: boolean;
|
allowQuickCreate?: boolean;
|
||||||
|
initialKind?: "mailbox" | "access" | undefined;
|
||||||
|
initialAddress?: string;
|
||||||
|
initialIntegration?: ResourceInput["integration"];
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("resources");
|
const { t } = useTranslation("resources");
|
||||||
const prefix = useId();
|
const prefix = useId();
|
||||||
@ -52,43 +59,37 @@ export function ResourceForm({
|
|||||||
mounted.current = false;
|
mounted.current = false;
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
const referenceQuery = useQuery({
|
||||||
|
queryKey: inventoryKeys.company(companyId),
|
||||||
|
queryFn: () => inventoryApi.get(companyId),
|
||||||
|
});
|
||||||
const form = useForm<ResourceInput>({
|
const form = useForm<ResourceInput>({
|
||||||
resolver: zodResolver(ResourceInputSchema),
|
resolver: zodResolver(ResourceInputSchema),
|
||||||
defaultValues: resource?.values ?? {
|
defaultValues: resourceDefaults(
|
||||||
name: "",
|
items,
|
||||||
templateId: "",
|
resource,
|
||||||
active: true,
|
initialKind,
|
||||||
notes: "",
|
initialAddress,
|
||||||
characteristics: {},
|
initialIntegration
|
||||||
},
|
),
|
||||||
});
|
});
|
||||||
const values = form.watch();
|
const values = form.watch();
|
||||||
const templates = items.filter((item) => item.values.kind === "template" && item.effectiveActive);
|
const templates = items.filter((item) => item.values.kind === "template" && item.effectiveActive);
|
||||||
const template = templates.find((item) => item.id === values.templateId);
|
const template = templates.find((item) => item.id === values.templateId);
|
||||||
const fields =
|
const fields = resourceFields(resource, template, items);
|
||||||
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 mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (input: ResourceInput) =>
|
mutationFn: (input: ResourceInput) =>
|
||||||
inventoryApi.save(companyId, input, csrfToken, resource?.id),
|
inventoryApi.save(companyId, input, csrfToken, resource?.id),
|
||||||
onSuccess: async (response) => {
|
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) {
|
if (mounted.current) {
|
||||||
onSaved(response.resource.id);
|
onSaved(response.resource.id);
|
||||||
}
|
}
|
||||||
@ -108,7 +109,20 @@ export function ResourceForm({
|
|||||||
};
|
};
|
||||||
return (
|
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}>
|
<fieldset className="space-y-5" disabled={mutation.isPending}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`${prefix}-name`}>{t("name")}</Label>
|
<Label htmlFor={`${prefix}-name`}>{t("name")}</Label>
|
||||||
@ -129,6 +143,7 @@ export function ResourceForm({
|
|||||||
onValueChange={(templateId) => {
|
onValueChange={(templateId) => {
|
||||||
form.setValue("templateId", templateId);
|
form.setValue("templateId", templateId);
|
||||||
form.setValue("characteristics", {});
|
form.setValue("characteristics", {});
|
||||||
|
form.setValue("integration", undefined);
|
||||||
}}
|
}}
|
||||||
options={
|
options={
|
||||||
resource
|
resource
|
||||||
@ -163,6 +178,16 @@ export function ResourceForm({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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 && (
|
{fields.length > 0 && (
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h3 className="font-medium">{t("characteristics")}</h3>
|
<h3 className="font-medium">{t("characteristics")}</h3>
|
||||||
@ -170,7 +195,7 @@ export function ResourceForm({
|
|||||||
{fields.map((field) => (
|
{fields.map((field) => (
|
||||||
<ResourceCharacteristicControl
|
<ResourceCharacteristicControl
|
||||||
disabled={mutation.isPending}
|
disabled={mutation.isPending}
|
||||||
field={field}
|
field={requireMailboxAddress(field, items, values, template)}
|
||||||
key={field.fieldId}
|
key={field.fieldId}
|
||||||
onChange={(value) => setCharacteristic(field.fieldId, value)}
|
onChange={(value) => setCharacteristic(field.fieldId, value)}
|
||||||
prefix={prefix}
|
prefix={prefix}
|
||||||
@ -184,7 +209,9 @@ export function ResourceForm({
|
|||||||
<Label htmlFor={`${prefix}-notes`}>{t("notes")}</Label>
|
<Label htmlFor={`${prefix}-notes`}>{t("notes")}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id={`${prefix}-notes`}
|
id={`${prefix}-notes`}
|
||||||
maxLength={10000}
|
maxLength={
|
||||||
|
values.integration || template?.builtinKey === "template:mailbox" ? 4000 : 10000
|
||||||
|
}
|
||||||
rows={3}
|
rows={3}
|
||||||
{...form.register("notes")}
|
{...form.register("notes")}
|
||||||
/>
|
/>
|
||||||
@ -195,7 +222,7 @@ export function ResourceForm({
|
|||||||
{t("inventoryErrors.INVALID_REQUEST")}
|
{t("inventoryErrors.INVALID_REQUEST")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<InventoryFeedback error={mutation.error} />
|
<InventoryFeedback error={mutation.error ?? referenceQuery.error} />
|
||||||
<ContextHelp
|
<ContextHelp
|
||||||
description={t("resourceDescription")}
|
description={t("resourceDescription")}
|
||||||
examples={t("resourceExamples")}
|
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 } : {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
165
modules/resources/src/web/resource-integration-fields.tsx
Normal file
165
modules/resources/src/web/resource-integration-fields.tsx
Normal 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;
|
||||||
|
}
|
||||||
@ -4,15 +4,26 @@ import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
|||||||
import { Input } from "@repo/shadcn-ui/components/input";
|
import { Input } from "@repo/shadcn-ui/components/input";
|
||||||
import { Label } from "@repo/shadcn-ui/components/label";
|
import { Label } from "@repo/shadcn-ui/components/label";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import { useId, useState } from "react";
|
import { useId, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import type { ResourceDTO } from "../common";
|
||||||
|
|
||||||
import { catalogApi, catalogKeys } from "./catalog-api";
|
import { catalogApi, catalogKeys } from "./catalog-api";
|
||||||
import { inventoryApi, inventoryKeys } from "./inventory-api";
|
import { inventoryApi, inventoryKeys } from "./inventory-api";
|
||||||
import { ResourceDetail } from "./resource-detail";
|
import { ResourceDetail } from "./resource-detail";
|
||||||
import { InventoryFeedback, ResourceForm } from "./resource-form";
|
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 { t } = useTranslation("resources");
|
||||||
const searchId = useId();
|
const searchId = useId();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
@ -132,7 +143,8 @@ export function ResourcesPage({ companyId, csrfToken }: { companyId: string; csr
|
|||||||
title={viewed.values.name}
|
title={viewed.values.name}
|
||||||
wide
|
wide
|
||||||
>
|
>
|
||||||
<ResourceDetail resource={viewed} />
|
<ResourceDetail references={query.data?.references ?? []} resource={viewed} />
|
||||||
|
{relatedContent?.(viewed)}
|
||||||
</FormDialog>
|
</FormDialog>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user