Impresoras en perfil de usuario
This commit is contained in:
parent
03c1c30146
commit
f957dd40cf
@ -17,6 +17,7 @@ import {
|
||||
} from "@metis/identity/common";
|
||||
import { createResourceModule } from "@metis/resources/module";
|
||||
import { QueryTypes } from "sequelize";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { verifyAccess } from "./access.integration";
|
||||
import { createApp } from "./app";
|
||||
@ -25,7 +26,9 @@ import { createDatabase, readDatabaseEnvironment } from "./config/database";
|
||||
import { createIdentityRuntime } from "./infrastructure/http/identity-runtime";
|
||||
import { verifyInventory } from "./inventory.integration";
|
||||
import { verifyMail } from "./mail.integration";
|
||||
import { verifyPrinters } from "./printers.integration";
|
||||
import { verifyRelationships } from "./relationships.integration";
|
||||
import { verifyResourceMigration } from "./resource-migration.integration";
|
||||
import { verifyResources } from "./resources.integration";
|
||||
import { verifyWorkProfiles } from "./work-profiles.integration";
|
||||
import { verifyWorkstations } from "./workstations.integration";
|
||||
@ -35,7 +38,9 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
|
||||
// Este proceso solo puede limpiar la base de pruebas dedicada.
|
||||
assert.equal(environment.DB_NAME, "metis_desk_test");
|
||||
const database = createDatabase(environment);
|
||||
const identity = await createIdentityRuntime(database, environment);
|
||||
const identity = await createIdentityRuntime(database, environment, {
|
||||
legacyResourceFixtures: true,
|
||||
});
|
||||
const server = createApp(() => undefined, identity).listen(0, "127.0.0.1");
|
||||
try {
|
||||
await once(server, "listening");
|
||||
@ -49,6 +54,8 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
|
||||
await database.query("DELETE FROM mailboxes");
|
||||
await database.query("DELETE FROM folder_mapping_revisions");
|
||||
await database.query("DELETE FROM folder_mappings");
|
||||
await database.query("DELETE FROM printer_configuration_revisions");
|
||||
await database.query("DELETE FROM printer_configurations");
|
||||
await database.query("DELETE FROM work_profile_revisions");
|
||||
await database.query("DELETE FROM work_profiles");
|
||||
await database.query("DELETE FROM workstation_assets");
|
||||
@ -93,8 +100,8 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
|
||||
await database.query(
|
||||
"ALTER TABLE company_contacts DROP COLUMN isContact, MODIFY position VARCHAR(100) NOT NULL, MODIFY email VARCHAR(254) NOT NULL, MODIFY phone VARCHAR(50) NOT NULL"
|
||||
);
|
||||
await createCompanyModule(database, (_request, _response, next) => next());
|
||||
await createCompanyModule(database, (_request, _response, next) => next());
|
||||
await createCompanyModule(database, (_request, _response, next) => next(), true);
|
||||
await createCompanyModule(database, (_request, _response, next) => next(), true);
|
||||
const legacy = await database.query<{ id: string; email: string; isContact: number }>(
|
||||
"SELECT id,email,isContact FROM company_contacts WHERE id=:id",
|
||||
{ replacements: { id: legacyPersonId }, type: QueryTypes.SELECT }
|
||||
@ -750,6 +757,41 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
|
||||
secondCompany.id,
|
||||
database
|
||||
);
|
||||
await verifyPrinters(
|
||||
(...args) => operator.request(...args),
|
||||
customer.id,
|
||||
secondCompany.id,
|
||||
database
|
||||
);
|
||||
// El entorno normal bloquea las fichas antiguas; el modo de fixtures anterior no se usa aquí.
|
||||
const migratedRuntime = await createIdentityRuntime(database, environment);
|
||||
const migratedServer = createApp(() => undefined, migratedRuntime).listen(0, "127.0.0.1");
|
||||
try {
|
||||
await once(migratedServer, "listening");
|
||||
const address = z.object({ port: z.number() }).parse(migratedServer.address());
|
||||
const session = await operator.session();
|
||||
await verifyResourceMigration(
|
||||
(path, body) =>
|
||||
fetch(`http://127.0.0.1:${address.port}/api${path}`, {
|
||||
method: body === undefined ? "GET" : "POST",
|
||||
headers: {
|
||||
cookie: operator.cookie(),
|
||||
origin: environment.APP_ORIGIN,
|
||||
"content-type": "application/json",
|
||||
"x-csrf-token": session.csrfToken,
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
}),
|
||||
customer.id,
|
||||
secondCompany.id,
|
||||
database
|
||||
);
|
||||
} finally {
|
||||
migratedRuntime.stop();
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
migratedServer.close((error) => (error ? reject(error) : resolve()))
|
||||
);
|
||||
}
|
||||
const countAfterAccess = await database.query<{ total: number }>(
|
||||
"SELECT COUNT(*) AS total FROM users",
|
||||
{ type: QueryTypes.SELECT }
|
||||
|
||||
@ -10,16 +10,22 @@ import type { readDatabaseEnvironment } from "../../config/database";
|
||||
|
||||
export async function createIdentityRuntime(
|
||||
database: Sequelize,
|
||||
environment: ReturnType<typeof readDatabaseEnvironment>
|
||||
environment: ReturnType<typeof readDatabaseEnvironment>,
|
||||
options: { legacyResourceFixtures?: boolean } = {}
|
||||
) {
|
||||
await database.authenticate();
|
||||
await migrateIdentity(database);
|
||||
const identity = await createIdentityModule(database, environment.APP_ORIGIN);
|
||||
const companies = await createCompanyModule(database, identity.authenticate);
|
||||
const companies = await createCompanyModule(
|
||||
database,
|
||||
identity.authenticate,
|
||||
options.legacyResourceFixtures === true && environment.NODE_ENV === "test"
|
||||
);
|
||||
const assets = await createAssetModule(database, identity.authenticate);
|
||||
const resources = await createResourceModule(database, identity.authenticate);
|
||||
const mail = await companies.initializeMail(assets.services.inventory);
|
||||
const profiles = await companies.initializeProfiles(assets.services.inventory);
|
||||
await resources.migrateLegacy();
|
||||
const SequelizeStore = connectSequelize(session.Store);
|
||||
const store = new SequelizeStore({
|
||||
db: database,
|
||||
|
||||
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;
|
||||
}
|
||||
const initial = await read(global);
|
||||
assert.equal(initial.items.length, 16);
|
||||
assert.equal(initial.items.filter((item) => item.values.kind === "template").length, 4);
|
||||
assert.equal(initial.items.length, 18);
|
||||
assert.equal(initial.items.filter((item) => item.values.kind === "template").length, 5);
|
||||
assert.ok(initial.items.every((item) => item.builtin && item.id[14] === "7"));
|
||||
assert.ok(
|
||||
initial.items.every((item) =>
|
||||
@ -240,7 +240,10 @@ export async function verifyResources(
|
||||
templateId: stockTemplate.id,
|
||||
active: true,
|
||||
notes: "",
|
||||
characteristics: {},
|
||||
characteristics: {
|
||||
[initial.items.find((item) => item.builtinKey === "field:email")?.id ?? ""]:
|
||||
"concurrent@example.test",
|
||||
},
|
||||
};
|
||||
const concurrent = await Promise.all([
|
||||
request(inventory, { values: concurrentInput }),
|
||||
|
||||
@ -61,6 +61,8 @@ Mailbox y MailboxUse son raíces de buzón y periodo de uso por persona. Mailbox
|
||||
|
||||
MailConfiguration es el agregado de un buzón configurado en un perfil; MailConfigurationRevision conserva cada instantánea con UUID propio. MailConfigurationValues encapsula referencias UniqueID y dirección EmailAddress. Consultar [Correos en dispositivos](../correos-en-dispositivos.md).
|
||||
|
||||
PrinterConfiguration es la raíz de una impresora configurada en un perfil y PrinterConfigurationRevision conserva cada instantánea con UUID propio. PrinterConfigurationValues encapsula referencias UniqueID, conexión, predeterminada, estado y notas opcionales Maybe. Consultar [Impresoras del perfil](../impresoras-del-perfil.md).
|
||||
|
||||
Los módulos de documentos y tickets todavía no existen. Se declararán como agregados o entidades al implementar sus operaciones y reglas de consistencia. La decisión de pertenencia a un agregado se toma por sus invariantes y transacciones; no convierte automáticamente cada tabla en una raíz ni fuerza todo el cliente y sus tickets dentro de un solo agregado.
|
||||
|
||||
Cada entidad que se incorpore deberá tener UniqueID desde su primera implementación, mapper de persistencia y pruebas de identidad, invariantes, rehidratación y actualización.
|
||||
|
||||
@ -4,7 +4,7 @@ Un buzón identifica una dirección de correo, independiente de la cuenta inform
|
||||
|
||||
## Buzones
|
||||
|
||||
La sección «Buzones de correo» permite crear y editar dirección, tipo corporativo o externo, contexto opcional, estado activo, notas y una cuenta informática relacionada opcional. El dominio de la dirección no determina su propiedad: una compañía puede trabajar con varios dominios y las personas pueden utilizar correos externos personales, de clientes, proveedores o colaboraciones.
|
||||
La sección «Recursos», mediante la plantilla «Buzón de correo», permite crear y editar dirección, tipo corporativo o externo, contexto opcional, estado activo, notas y una cuenta informática relacionada opcional. La ficha muestra su historial y los periodos de uso por persona. El dominio de la dirección no determina su propiedad: una compañía puede trabajar con varios dominios y las personas pueden utilizar correos externos personales, de clientes, proveedores o colaboraciones.
|
||||
|
||||
La cuenta relacionada es solo una referencia documental. No concede acceso ni es obligatoria. Su desactivación o caducidad no sustituye la revisión del buzón y sus usos. Dirección y contexto se pueden actualizar con historial; el tipo queda fijado desde el alta. La dirección se normaliza y es única dentro de la compañía, incluso en buzones inactivos.
|
||||
|
||||
@ -43,12 +43,12 @@ Los registros abiertos anteriores aparecen como pendientes de vinculación. Edit
|
||||
|
||||
## Arquitectura y API
|
||||
|
||||
Mailbox y MailboxUse son AggregateRoot; sus revisiones son DomainEntity. MailboxValues y MailboxUseValues son Value Objects inmutables, con EmailAddress, UniqueID, CalendarDate y Maybe. MailConfiguration conserva su agregado y revisiones, con referencias al perfil y al buzón. Las altas generan UUID v7; actualizar y rehidratar conservan la identidad.
|
||||
Resource es el agregado editable del buzón. Mailbox permanece como proyección de compatibilidad de lectura y MailboxUse mantiene su AggregateRoot; sus revisiones son DomainEntity. MailboxValues y MailboxUseValues son Value Objects inmutables, con EmailAddress, UniqueID, CalendarDate y Maybe. MailConfiguration conserva su agregado y revisiones, con referencias al perfil y al buzón. Las altas generan UUID v7; actualizar y rehidratar conservan la identidad.
|
||||
|
||||
Las migraciones explícitas e idempotentes crean mailboxes, mailbox_uses y sus revisiones, y amplían mail_configurations. La persistencia verifica referencias, pertenencia, historial ordenado, última revisión y claves derivadas. Las escrituras son transaccionales, bloqueando primero la compañía. El inventario se consulta mediante el servicio público de activos, inyectado en compañías.
|
||||
|
||||
- GET /api/mailboxes/:companyId devuelve buzones, usos e historial.
|
||||
- POST /api/mailboxes/:companyId y POST /api/mailboxes/:companyId/:id/update reciben values del buzón.
|
||||
- POST /api/resources/:companyId y POST /api/resources/:companyId/:id/update guardan el buzón como recurso. Las antiguas escrituras de buzón devuelven 409 RESOURCE_MOVED.
|
||||
- POST /api/mailboxes/:companyId/uses y POST /api/mailboxes/:companyId/uses/:id/update reciben values del periodo.
|
||||
- GET /api/mail-configurations/:companyId devuelve configuraciones, revisiones, legacy y needsReview calculado.
|
||||
- POST /api/mail-configurations/:companyId y POST /api/mail-configurations/:companyId/:id/update reciben values con profileId, mailboxId, method, state y notes.
|
||||
@ -60,3 +60,8 @@ Sesión obligatoria; las escrituras exigen origen válido y CSRF. El actor se ob
|
||||
Incluye buzones corporativos y externos, varios buzones por persona, varios perfiles por buzón, periodos de uso, revisión de retirada y conservación del legado. Los dispositivos externos personales, programas, carpetas, impresoras, certificados, listas de baja, licencias y roles quedan pendientes.
|
||||
|
||||
Las pruebas cubren fechas y solapes, permisos vigentes, múltiples buzones/perfiles/personas, UUID e historial, idempotencia, ámbito, concurrencia, protección HTTP, retirada, vinculación explícita del legado, corrupción persistida y textos es/en con ayuda inferior.
|
||||
|
||||
|
||||
## Traspaso al catálogo de recursos
|
||||
|
||||
Los buzones existentes y todas sus revisiones se han migrado manteniendo los UUID, actores, fechas y relaciones. Su edición ya utiliza Resource. Las tablas antiguas se conservan como proyecciones transaccionales para los usos y configuraciones actuales, sin gestión independiente. Véase [Recursos, campos y plantillas](recursos-campos-y-plantillas.md).
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
## Dónde se gestionan
|
||||
|
||||
- Catálogos informáticos, en la navegación principal: sistemas, tipos de recurso, permisos disponibles y plantillas de grupos comunes.
|
||||
- Compañías → ficha → Cuentas y accesos: cuentas, relaciones con personas, grupos, miembros, recursos, permisos concedidos, necesidades, cobertura y entornos. También permite añadir sistemas, tipos y permisos propios de la compañía.
|
||||
- Compañías → ficha → Cuentas y accesos: cuentas, relaciones con personas, grupos, miembros, permisos concedidos, necesidades, cobertura y entornos. También permite añadir sistemas, tipos y permisos propios de la compañía.
|
||||
- Los botones de alta están a la derecha de la cabecera. Los formularios sencillos se abren en modal; los extensos sustituyen el listado por una página de formulario. Edición e historial son acciones explícitas.
|
||||
|
||||
Los catálogos comienzan vacíos. Definir primero los sistemas comunes que se utilicen, como Dominio, Correo, Microsoft 365 o ERP. Las altas posteriores seleccionan esas definiciones; no se crean datos de ejemplo en la base de trabajo.
|
||||
@ -79,7 +79,7 @@ Cada cambio registra una revisión con UUID, fecha y usuario de Metis Desk. La i
|
||||
|
||||
## Recursos y permisos
|
||||
|
||||
Cada recurso tiene nombre, tipo, estado, ubicación o referencia opcional, sistema y entorno opcionales y observaciones. Puede existir antes de inventariar el activo que lo aloja. Su nombre es único por compañía, tipo, sistema y entorno.
|
||||
Los recursos se gestionan ahora en Compañías → Recursos, mediante la plantilla Recurso de acceso. Conservan nombre, tipo, estado, referencia opcional, sistema y entorno opcionales y observaciones. Puede existir antes de inventariar el activo que lo aloja. Su nombre es único por compañía, tipo, sistema y entorno.
|
||||
|
||||
La ubicación o referencia puede cambiar manteniendo el UUID. Tipo, sistema y entorno identifican el contexto del recurso y quedan fijos.
|
||||
|
||||
@ -143,3 +143,10 @@ Pasan typecheck, lint, test, check y build. La compilación conserva el aviso pr
|
||||
## Pendiente para futuras entregas
|
||||
|
||||
Configuración por ordenador o móvil, POP/IMAP, teletrabajo y acceso remoto, inventario y modelos de activos, procesos y plantillas de alta/cambio/salida, relación con licencias e integraciones con sistemas externos.
|
||||
|
||||
|
||||
## Migración de recursos básicos
|
||||
|
||||
Los antiguos AccessItem de tipo resource se han traspasado a Resource sin cambiar sus UUID ni los de sus revisiones. Permisos, necesidades, vías de cobertura y mapeos de carpetas siguen enlazados. El formulario anterior y la pestaña de recursos se han retirado; el alta rápida desde permisos o necesidades abre el formulario del nuevo modelo.
|
||||
|
||||
access_items y su historial de tipo resource son proyecciones de compatibilidad que resources actualiza dentro de su transacción. Las escrituras antiguas de ese tipo devuelven 409 RESOURCE_MOVED; cuentas, grupos, permisos y necesidades mantienen sus rutas. Véase [Recursos, campos y plantillas](recursos-campos-y-plantillas.md).
|
||||
|
||||
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 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.
|
||||
|
||||
@ -49,14 +49,17 @@ Buzones independientes corporativos o externos, cuenta informática opcional, pe
|
||||
|
||||
## Recursos con campos y plantillas: base implementada
|
||||
|
||||
Catálogo independiente de activos, 12 campos y cuatro plantillas de serie, adiciones globales y por compañía, disponibilidad local y recursos con especificación capturada e historial. Los buzones y recursos básicos anteriores permanecen sin migrar. Consultar [Recursos, campos y plantillas](recursos-campos-y-plantillas.md).
|
||||
Catálogo independiente de activos, 13 campos y cinco plantillas de serie, adiciones globales y por compañía, disponibilidad local y recursos con especificación capturada e historial. Los buzones y recursos básicos anteriores están migrados, conservando UUID, historial y relaciones. La gestión anterior se ha retirado; sus tablas se mantienen como proyecciones para permisos y configuraciones existentes. Consultar [Recursos, campos y plantillas](recursos-campos-y-plantillas.md).
|
||||
|
||||
## Impresoras por perfil: implementado
|
||||
|
||||
Configuraciones USB o red sobre activos de la compañía, una predeterminada por perfil, estados de preparación y retirada, historial y avisos de revisión. Disponible desde la compañía y cada perfil. Modales, ayuda inferior y altas rápidas. Consultar [Impresoras del perfil](impresoras-del-perfil.md).
|
||||
|
||||
## Siguientes entregas: orden actualizado
|
||||
|
||||
1. Revisar y migrar los buzones y recursos básicos al nuevo modelo; comprobar UUID, historial, accesos y configuraciones antes de retirar la gestión anterior.
|
||||
2. Configuraciones del perfil: carpetas compartidas, impresoras y recursos; después programas y certificados.
|
||||
3. Teletrabajo y accesos remotos: autorización, equipo de origen, conectividad VPN y destino. Asistencia técnica remota independiente.
|
||||
4. Procesos de alta, cambio y salida con seguimiento de configuraciones y retiradas. Plantillas reutilizables entre compañías; ejecuciones y datos propios.
|
||||
1. Configuraciones del perfil: aplicaciones y certificados como recursos; después ficha del entorno actual reuniendo correos, carpetas, impresoras y los nuevos recursos.
|
||||
2. Teletrabajo y accesos remotos: autorización, equipo de origen, conectividad VPN y destino. Asistencia técnica remota independiente.
|
||||
3. Procesos de alta, cambio y salida con seguimiento de configuraciones y retiradas. Plantillas reutilizables entre compañías; ejecuciones y datos propios.
|
||||
|
||||
Este orden se concreta antes de cada entrega. Las licencias y su relación con cuentas se explorarán posteriormente.
|
||||
|
||||
|
||||
@ -20,7 +20,7 @@ Cada compañía puede activar o desactivar una definición global para su ámbit
|
||||
|
||||
## Datos de serie
|
||||
|
||||
Se incluyen 12 campos y cuatro plantillas:
|
||||
Se incluyen 13 campos y cinco plantillas:
|
||||
|
||||
| Plantilla | Campos |
|
||||
| --- | --- |
|
||||
@ -28,10 +28,11 @@ Se incluyen 12 campos y cuatro plantillas:
|
||||
| Carpeta compartida | Ruta UNC, servidor como referencia documental y descripción |
|
||||
| Aplicación | Proveedor, URL y descripción |
|
||||
| Certificado digital | Titular, emisor, caducidad, huella digital y finalidad |
|
||||
| Recurso de acceso | Referencia; contexto estructurado de tipo de acceso, sistema y entorno |
|
||||
|
||||
Todas las características de serie son opcionales. Los datos se alimentan con claves de serie estables: reiniciar no duplica definiciones, cambia UUID ni reactiva elementos desactivados. No se crean recursos, personas o compañías de ejemplo.
|
||||
Las características de serie son opcionales salvo la dirección válida exigida al registrar un buzón. Los datos se alimentan con claves de serie estables: reiniciar no duplica definiciones, cambia UUID ni reactiva elementos desactivados. No se crean recursos, personas o compañías de ejemplo.
|
||||
|
||||
Esta primera base valida tipos de datos, fechas y valores de listas. Dirección de correo, URL y ruta UNC son características de texto; todavía no tienen validadores específicos de su significado ni vínculos estructurados con servidores. No se almacenan contraseñas ni claves privadas como funcionalidades de este catálogo.
|
||||
Se validan tipos, fechas, listas y dirección de correo de los buzones. La dirección se normaliza a minúsculas y es única en la compañía, incluso para buzones inactivos. URL y ruta UNC siguen siendo texto sin vínculos estructurados con servidores. No se almacenan contraseñas ni claves privadas como funcionalidades de este catálogo.
|
||||
|
||||
## Recursos registrados
|
||||
|
||||
@ -39,7 +40,7 @@ Un recurso pertenece a una compañía y tiene nombre, plantilla, estado activo,
|
||||
|
||||
La plantilla queda fija tras el alta. Su especificación se captura al crear el recurso: nombres de campos, tipos, unidades, listas y obligatoriedad. Cambiar después el catálogo no reescribe las fichas ya registradas. Una plantilla desactivada impide nuevas altas, pero permite consultar y editar los recursos existentes con su especificación original.
|
||||
|
||||
El nombre es único dentro de compañía y plantilla, incluyendo recursos inactivos. Las modificaciones conservan UUID y añaden revisión con fecha y usuario de Metis Desk. Repetir una edición equivalente no añade historial.
|
||||
El nombre es único dentro de compañía y plantilla, incluyendo recursos inactivos. Los recursos de acceso distinguen además tipo, sistema y entorno para conservar los contextos existentes. Las modificaciones conservan UUID y añaden revisión con fecha y usuario de Metis Desk. Repetir una edición equivalente no añade historial.
|
||||
|
||||
## Arquitectura y persistencia
|
||||
|
||||
@ -53,10 +54,26 @@ GET /api/resource-catalog/:scope consulta definiciones; POST en esa ruta crea; P
|
||||
|
||||
GET /api/resources/:companyId lista recursos; POST en esa ruta crea; POST /api/resources/:companyId/:id/update edita. Todas las rutas requieren sesión. Las escrituras exigen origen y token CSRF; el actor procede de la sesión.
|
||||
|
||||
## Convivencia y siguiente paso
|
||||
## Migración de buzones y recursos de acceso: implementada
|
||||
|
||||
Los buzones existentes, sus usos y configuraciones por perfil y los recursos básicos de Cuentas y accesos permanecen disponibles. Esta entrega no los copia, migra ni elimina. Sus UUID e historial se conservan.
|
||||
Al iniciar el servidor, después de las migraciones de correo y perfiles, se traspasan todos los buzones y los recursos básicos de Cuentas y accesos. El proceso usa una transacción y bloquea catálogo y compañías en orden estable. Reiniciar no duplica registros ni añade revisiones. Una colisión de identidad, un historial inválido o una última revisión incoherente detiene el traspaso y revierte la transacción.
|
||||
|
||||
Antes de migrarlos, se revisarán correspondencias, identidad, necesidades, permisos, cobertura y configuraciones por perfil. POP/IMAP, letras de unidad y versiones instaladas son datos de configuración, no características del recurso común.
|
||||
Cada recurso conserva su UUID original; cada revisión conserva UUID, actor, fecha, orden y valores equivalentes. No se crea una revisión artificial de migración. Los usos del buzón, configuraciones de correo, permisos, necesidades, cobertura y carpetas de perfiles conservan sus referencias.
|
||||
|
||||
Después de completar y comprobar esa migración se retirará la gestión anterior, evitando mantener dos fuentes para un mismo recurso. Los validadores específicos y las relaciones del nuevo recurso se concretarán junto con ese paso.
|
||||
Correspondencias:
|
||||
- Buzón → plantilla Buzón de correo; dirección y contexto pasan a características. Tipo corporativo/externo y cuenta relacionada opcional se conservan como contexto estructurado. El nombre inicial es la dirección (hasta 100 caracteres; la dirección completa permanece en su campo).
|
||||
- Recurso básico → plantilla Recurso de acceso; referencia pasa a característica. Nombre, notas, estado, tipo de acceso, sistema y entorno se conservan. No se deduce una plantilla especializada por su nombre o tipo.
|
||||
|
||||
La ficha editable y su historial pertenecen a Resource. La integración es parte de ResourceValues, con campos estrictos y comparación semántica. El tipo de buzón, los identificadores de sus campos y el contexto de acceso quedan fijos; la cuenta documental del buzón puede cambiar con historial.
|
||||
|
||||
En la interfaz desaparecen el listado Buzones de correo y la pestaña Recursos de Cuentas y accesos. Todos se crean y editan en Recursos mediante modal. La ficha de un buzón permite vincular personas o finalizar su uso. Las altas rápidas de buzón o recurso desde otros formularios guardan también en el nuevo modelo.
|
||||
|
||||
Para conservar las claves foráneas y los lectores actuales de permisos y perfiles, mailboxes/mailbox_revisions y access_items/access_revisions de tipo resource se mantienen como proyecciones de compatibilidad. El adaptador de infraestructura de resources actualiza estas proyecciones en la misma transacción que la ficha; solo añade nuevas revisiones y nunca reescribe las originales. No son un segundo catálogo editable. Las antiguas rutas de escritura de buzón y recurso básico devuelven 409 RESOURCE_MOVED; consultas, usos, permisos y configuraciones continúan disponibles.
|
||||
|
||||
POP/IMAP pertenece a la configuración de correo por perfil; letras de unidad pertenecen al mapeo de carpetas. No se incorporan a los campos del recurso común. La migración no configura equipos ni concede o retira permisos reales.
|
||||
|
||||
## Validación
|
||||
|
||||
Las pruebas con MariaDB de test comprueban UUID y revisiones originales, repetición idempotente, edición canónica y actualización de proyecciones, aislamiento por compañía y rechazo de escrituras antiguas. Se mantienen las pruebas de múltiples correos, permisos, necesidades, cobertura y perfiles. Las pruebas de dominio cubren correo inválido o ausente, normalización, cuenta opcional, cambio auditado y tipo fijo.
|
||||
|
||||
La comprobación en navegador usa los datos existentes de desarrollo, sin crear registros ficticios: ficha, historial, personas vinculadas, edición modal y selectores.
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/components/companies-page.test.tsx src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/api/domain/mailbox.test.ts src/web/access/access.test.tsx src/web/mail/mail.test.tsx src/api/domain/work-profile.test.ts src/web/profiles/profile.test.tsx"
|
||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/components/companies-page.test.tsx src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/api/domain/mailbox.test.ts src/web/access/access.test.tsx src/web/mail/mail.test.tsx src/api/domain/work-profile.test.ts src/web/profiles/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printers/printer.test.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/rdx-ddd": "workspace:*",
|
||||
|
||||
@ -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(
|
||||
app: Application,
|
||||
useCases: AccessUseCases,
|
||||
authenticate: RequestHandler
|
||||
authenticate: RequestHandler,
|
||||
legacyResourceWrites = false
|
||||
) {
|
||||
const router = Router();
|
||||
router.get("/:scope", (request, response, next) => {
|
||||
@ -74,6 +75,10 @@ export function registerAccessRoutes(
|
||||
sendError(response, "INVALID_REQUEST", 400);
|
||||
return;
|
||||
}
|
||||
if (!legacyResourceWrites && body.data.values.kind === "resource") {
|
||||
sendError(response, "RESOURCE_MOVED", 409);
|
||||
return;
|
||||
}
|
||||
const id = z.object({ id: z.uuid() }).safeParse(params.data);
|
||||
const identity = id.success ? id.data.id : undefined;
|
||||
useCases
|
||||
|
||||
@ -14,7 +14,8 @@ function fail(response: Response, code: string) {
|
||||
export function registerMailboxRoutes(
|
||||
app: Application,
|
||||
useCases: MailboxUseCases,
|
||||
authenticate: RequestHandler
|
||||
authenticate: RequestHandler,
|
||||
legacyResourceWrites = false
|
||||
) {
|
||||
const router = Router();
|
||||
router.get("/:companyId", (request, response, next) => {
|
||||
@ -61,6 +62,10 @@ export function registerMailboxRoutes(
|
||||
fail(response, "INVALID_REQUEST");
|
||||
return;
|
||||
}
|
||||
if (!legacyResourceWrites && "address" in body.data.values) {
|
||||
fail(response, "RESOURCE_MOVED");
|
||||
return;
|
||||
}
|
||||
const id =
|
||||
"id" in params.data && typeof params.data.id === "string" ? params.data.id : undefined;
|
||||
useCases
|
||||
|
||||
@ -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 { Sequelize } from "sequelize";
|
||||
|
||||
import { AccessUseCases } from "./application/access.use-cases";
|
||||
import { CompanyUseCases } from "./application/company.use-cases";
|
||||
import { CompanyInputMapper } from "./application/company-input.mapper";
|
||||
import { FolderMappingUseCases } from "./application/folder-mapping.use-cases";
|
||||
import type { IMailInventoryService } from "./application/mail-configuration.repository";
|
||||
import { MailConfigurationUseCases } from "./application/mail-configuration.use-cases";
|
||||
import { MailboxUseCases } from "./application/mailbox.use-cases";
|
||||
import { PrinterConfigurationUseCases } from "./application/printer-configuration.use-cases";
|
||||
import type { IWorkProfileInventoryService } from "./application/work-profile.repository";
|
||||
import { WorkProfileUseCases } from "./application/work-profile.use-cases";
|
||||
import { registerAccessRoutes } from "./infrastructure/http/access.routes";
|
||||
import { registerCompanyRoutes } from "./infrastructure/http/company.routes";
|
||||
import { registerFolderMappingRoutes } from "./infrastructure/http/folder-mapping.routes";
|
||||
import { registerMailRoutes } from "./infrastructure/http/mail-configuration.routes";
|
||||
import { registerMailboxRoutes } from "./infrastructure/http/mailbox.routes";
|
||||
import { registerPrinterConfigurationRoutes } from "./infrastructure/http/printer-configuration.routes";
|
||||
import { registerWorkProfileRoutes } from "./infrastructure/http/work-profile.routes";
|
||||
import {
|
||||
AccessPersistenceMapper,
|
||||
@ -25,18 +25,26 @@ import {
|
||||
} from "./infrastructure/persistence/access-persistence";
|
||||
import { initializeCompanyModels } from "./infrastructure/persistence/company.model";
|
||||
import { CompanyPersistenceMapper } from "./infrastructure/persistence/company-persistence.mapper";
|
||||
import { migrateFolderMappings } from "./infrastructure/persistence/folder-mapping-migration";
|
||||
import { migrateMailConfigurations } from "./infrastructure/persistence/mail-configuration-migration";
|
||||
import { migrateMailboxes } from "./infrastructure/persistence/mailbox-migration";
|
||||
import { migrateCompanies } from "./infrastructure/persistence/migrate-companies";
|
||||
import { migratePeople } from "./infrastructure/persistence/migrate-people";
|
||||
import { initializePeopleModels } from "./infrastructure/persistence/people.model";
|
||||
import { migratePrinterConfigurations } from "./infrastructure/persistence/printer-configuration-migration";
|
||||
import { SequelizeCompanyRepository } from "./infrastructure/persistence/sequelize-company.repository";
|
||||
import { SequelizeFolderMappingRepository } from "./infrastructure/persistence/sequelize-folder-mapping.repository";
|
||||
import { SequelizeMailConfigurationRepository } from "./infrastructure/persistence/sequelize-mail-configuration.repository";
|
||||
import { SequelizeMailboxRepository } from "./infrastructure/persistence/sequelize-mailbox.repository";
|
||||
import { SequelizePrinterConfigurationRepository } from "./infrastructure/persistence/sequelize-printer-configuration.repository";
|
||||
import { SequelizeWorkProfileRepository } from "./infrastructure/persistence/sequelize-work-profile.repository";
|
||||
import { migrateWorkProfiles } from "./infrastructure/persistence/work-profile-migration";
|
||||
|
||||
export async function createCompanyModule(database: Sequelize, authenticate: RequestHandler) {
|
||||
export async function createCompanyModule(
|
||||
database: Sequelize,
|
||||
authenticate: RequestHandler,
|
||||
legacyResourceWrites = false
|
||||
) {
|
||||
await migrateCompanies(database);
|
||||
await migratePeople(database);
|
||||
initializeCompanyModels(database);
|
||||
@ -51,7 +59,13 @@ export async function createCompanyModule(database: Sequelize, authenticate: Req
|
||||
initializeProfiles: async (inventory: IWorkProfileInventoryService) => {
|
||||
await migrateWorkProfiles(database);
|
||||
await migrateFolderMappings(database);
|
||||
const folders=new FolderMappingUseCases(new SequelizeFolderMappingRepository(database,inventory));
|
||||
await migratePrinterConfigurations(database);
|
||||
const printers = new PrinterConfigurationUseCases(
|
||||
new SequelizePrinterConfigurationRepository(database, inventory)
|
||||
);
|
||||
const folders = new FolderMappingUseCases(
|
||||
new SequelizeFolderMappingRepository(database, inventory)
|
||||
);
|
||||
const profiles = new WorkProfileUseCases(
|
||||
new SequelizeWorkProfileRepository(database, inventory)
|
||||
);
|
||||
@ -59,6 +73,7 @@ export async function createCompanyModule(database: Sequelize, authenticate: Req
|
||||
register: (app: Parameters<typeof registerWorkProfileRoutes>[0]) => {
|
||||
registerWorkProfileRoutes(app, profiles, authenticate);
|
||||
registerFolderMappingRoutes(app, folders, authenticate);
|
||||
registerPrinterConfigurationRoutes(app, printers, authenticate);
|
||||
},
|
||||
};
|
||||
},
|
||||
@ -74,13 +89,13 @@ export async function createCompanyModule(database: Sequelize, authenticate: Req
|
||||
return {
|
||||
register: (app: Parameters<typeof registerMailRoutes>[0]) => {
|
||||
registerMailRoutes(app, mail, authenticate);
|
||||
registerMailboxRoutes(app, mailboxes, authenticate);
|
||||
registerMailboxRoutes(app, mailboxes, authenticate, legacyResourceWrites);
|
||||
},
|
||||
};
|
||||
},
|
||||
register: (app: Parameters<typeof registerCompanyRoutes>[0]) => {
|
||||
registerCompanyRoutes(app, useCases, authenticate);
|
||||
registerAccessRoutes(app, access, authenticate);
|
||||
registerAccessRoutes(app, access, authenticate, legacyResourceWrites);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -5,8 +5,8 @@ export * from "./dto/response/company.response.dto";
|
||||
export * from "./dto/shared/access.dto";
|
||||
export * from "./dto/shared/company.dto";
|
||||
export * from "./dto/shared/people.dto";
|
||||
export * from "./folder-mappings";
|
||||
export * from "./mail-configurations";
|
||||
export * from "./mailboxes";
|
||||
export * from "./printer-configurations";
|
||||
export * from "./work-profiles";
|
||||
|
||||
export * from "./folder-mappings";
|
||||
|
||||
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 { FormActions, FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@ -26,7 +27,8 @@ import { EntityHelp } from "./entity-help";
|
||||
import { QuickCreateDialog } from "./quick-create-dialog";
|
||||
import type { QuickCreatePlan } from "./quick-create-plan";
|
||||
import { matchesSubmittedValues, quickCreatePlan } from "./quick-create-plan";
|
||||
export function AccessForm({
|
||||
|
||||
function AccessFormInternal({
|
||||
scope,
|
||||
kind,
|
||||
item,
|
||||
@ -330,3 +332,25 @@ function AccessQuickEditor({
|
||||
</FormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccessForm(props: Parameters<typeof AccessFormInternal>[0]) {
|
||||
if (props.kind === "resource") {
|
||||
const type = props.initialValues?.resourceTypeId;
|
||||
return (
|
||||
<ResourceEditor
|
||||
allowQuickCreate={props.allowQuickCreate ?? true}
|
||||
companyId={props.scope}
|
||||
csrfToken={props.csrfToken}
|
||||
initialAccess={
|
||||
typeof type === "string" && type
|
||||
? { resourceTypeId: type, systemId: null, environmentId: null }
|
||||
: undefined
|
||||
}
|
||||
initialKind="access"
|
||||
onSaved={(id) => props.onSaved(id)}
|
||||
resourceId={props.item?.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <AccessFormInternal {...props} />;
|
||||
}
|
||||
|
||||
@ -55,7 +55,6 @@ export function AccessWorkspacePage({
|
||||
"account_person",
|
||||
"group",
|
||||
"membership",
|
||||
"resource",
|
||||
"grant",
|
||||
"need",
|
||||
"coverage",
|
||||
|
||||
@ -262,14 +262,7 @@ test("los formularios ofrecen altas relacionadas y el segundo modal evita altas
|
||||
</QueryClientProvider>
|
||||
</I18nextProvider>
|
||||
);
|
||||
for (const kind of [
|
||||
"permission",
|
||||
"account_person",
|
||||
"resource",
|
||||
"grant",
|
||||
"need",
|
||||
"group",
|
||||
] as const) {
|
||||
for (const kind of ["permission", "account_person", "grant", "need", "group"] as const) {
|
||||
assert.ok(render(kind).includes('data-slot="button-group"'), kind);
|
||||
assert.equal(render(kind, false).includes('data-slot="button-group"'), false, kind);
|
||||
}
|
||||
|
||||
@ -23,7 +23,8 @@ import { AccessWorkspacePage } from "../access/access-workspace";
|
||||
import { companyApi, companyKeys } from "../api/company-api";
|
||||
import { FolderMappingsPage } from "../folders/folder-mappings-page";
|
||||
import { MailConfigurationsPage } from "../mail/mail-configurations-page";
|
||||
import { MailboxesPage } from "../mail/mailboxes-page";
|
||||
import { MailboxRelations } from "../mail/mailbox-relations";
|
||||
import { PrinterConfigurationsPage } from "../printers/printer-configurations-page";
|
||||
import { WorkProfilesPage } from "../profiles/work-profiles-page";
|
||||
|
||||
import { CompanyFeedback } from "./company-feedback";
|
||||
@ -116,8 +117,8 @@ type CompanyTab =
|
||||
| "assets"
|
||||
| "inventory"
|
||||
| "workstations"
|
||||
| "printers"
|
||||
| "folders"
|
||||
| "mailboxes"
|
||||
| "mail"
|
||||
| "profiles";
|
||||
|
||||
@ -220,7 +221,7 @@ export function CompanyDetail({
|
||||
["sites", `${t("sites")} (${company.sites.length})`, MapPinIcon],
|
||||
["access", accessTranslation("title"), KeyRoundIcon],
|
||||
["folders", t("folders.title"), MailIcon],
|
||||
["mailboxes", t("mailbox.title"), MailIcon],
|
||||
["printers", t("printers.title"), LayersIcon],
|
||||
["mail", t("mail.title"), MailIcon],
|
||||
["assets", assetTranslation("companyTitle"), LayoutTemplateIcon],
|
||||
["resource-catalog", resourceTranslation("companyTitle"), LayoutTemplateIcon],
|
||||
@ -270,10 +271,10 @@ function CompanySection({
|
||||
);
|
||||
case "profiles":
|
||||
return <WorkProfilesPage company={company} csrfToken={csrfToken} />;
|
||||
case "printers":
|
||||
return <PrinterConfigurationsPage company={company} csrfToken={csrfToken} />;
|
||||
case "folders":
|
||||
return <FolderMappingsPage company={company} csrfToken={csrfToken} />;
|
||||
case "mailboxes":
|
||||
return <MailboxesPage company={company} csrfToken={csrfToken} />;
|
||||
case "mail":
|
||||
return <MailConfigurationsPage company={company} csrfToken={csrfToken} />;
|
||||
case "workstations":
|
||||
@ -281,7 +282,17 @@ function CompanySection({
|
||||
case "inventory":
|
||||
return <CompanyInventory company={company} csrfToken={csrfToken} />;
|
||||
case "resources":
|
||||
return <ResourcesPage companyId={company.id} csrfToken={csrfToken} />;
|
||||
return (
|
||||
<ResourcesPage
|
||||
companyId={company.id}
|
||||
csrfToken={csrfToken}
|
||||
relatedContent={(resource) =>
|
||||
resource.values.integration?.kind === "mailbox" ? (
|
||||
<MailboxRelations company={company} csrfToken={csrfToken} mailboxId={resource.id} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
case "resource-catalog":
|
||||
return <ResourceCatalogPage csrfToken={csrfToken} scope={company.id} />;
|
||||
case "assets":
|
||||
|
||||
@ -18,6 +18,7 @@ const titles = {
|
||||
} as const;
|
||||
export function ConfigurationQuickEditor({
|
||||
kind,
|
||||
title,
|
||||
company,
|
||||
csrfToken,
|
||||
values,
|
||||
@ -26,6 +27,7 @@ export function ConfigurationQuickEditor({
|
||||
onClose,
|
||||
}: {
|
||||
kind: ConfigurationQuickKind;
|
||||
title?: string;
|
||||
company: CompanyDTO;
|
||||
csrfToken: string;
|
||||
values: Readonly<{ personId: string; accountId: string }>;
|
||||
@ -65,7 +67,7 @@ export function ConfigurationQuickEditor({
|
||||
}
|
||||
}}
|
||||
open
|
||||
title={t(`mail.${titles[kind]}`)}
|
||||
title={title ?? t(`mail.${titles[kind]}`)}
|
||||
wide
|
||||
>
|
||||
{kind === "person" && (
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
import {foldersSpanish,foldersEnglish} from "./folders/folder-i18n";
|
||||
import { foldersEnglish, foldersSpanish } from "./folders/folder-i18n";
|
||||
import { mailEnglish, mailSpanish } from "./mail/mail-i18n";
|
||||
import { mailboxEnglish, mailboxSpanish } from "./mail/mailbox-i18n";
|
||||
import { printersEnglish, printersSpanish } from "./printers/printer-i18n";
|
||||
import { profilesEnglish, profilesSpanish } from "./profiles/profile-i18n";
|
||||
|
||||
export const companiesSpanish = {
|
||||
printers: printersSpanish,
|
||||
mail: mailSpanish,
|
||||
mailbox: mailboxSpanish,
|
||||
folders:foldersSpanish,
|
||||
folders: foldersSpanish,
|
||||
profiles: profilesSpanish,
|
||||
companySpaces: "Espacios de compañías",
|
||||
globalCatalogs: "Catálogos globales",
|
||||
@ -122,9 +124,10 @@ export const companiesSpanish = {
|
||||
},
|
||||
};
|
||||
export const companiesEnglish = {
|
||||
printers: printersEnglish,
|
||||
mail: mailEnglish,
|
||||
mailbox: mailboxEnglish,
|
||||
folders:foldersEnglish,
|
||||
folders: foldersEnglish,
|
||||
profiles: profilesEnglish,
|
||||
companySpaces: "Company spaces",
|
||||
globalCatalogs: "Global catalogs",
|
||||
|
||||
@ -2,6 +2,12 @@ import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { inventoryKeys } from "@metis/assets/client";
|
||||
import {
|
||||
catalogKeys as resourceCatalogKeys,
|
||||
inventoryKeys as resourceKeys,
|
||||
resourcesEnglish,
|
||||
resourcesSpanish,
|
||||
} from "@metis/resources/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createInstance } from "i18next";
|
||||
import type { ReactNode } from "react";
|
||||
@ -17,7 +23,7 @@ import { MailConfigurationForm } from "./mail-configuration-form";
|
||||
import { MailConfigurationsPage } from "./mail-configurations-page";
|
||||
import { mailboxKeys } from "./mailbox-api";
|
||||
import { MailboxForm } from "./mailbox-form";
|
||||
import { MailboxesPage } from "./mailboxes-page";
|
||||
import { MailboxRelations } from "./mailbox-relations";
|
||||
|
||||
const company = {
|
||||
id: "0199b200-0000-7000-8000-000000000001",
|
||||
@ -33,11 +39,47 @@ async function render(language: string, node: ReactNode) {
|
||||
const i18n = createInstance();
|
||||
await i18n.init({
|
||||
lng: language,
|
||||
resources: { es: { companies: companiesSpanish }, en: { companies: companiesEnglish } },
|
||||
resources: {
|
||||
es: { companies: companiesSpanish, resources: resourcesSpanish },
|
||||
en: { companies: companiesEnglish, resources: resourcesEnglish },
|
||||
},
|
||||
});
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY } },
|
||||
});
|
||||
const addressId = "0199b200-0000-7000-8000-000000000002";
|
||||
const contextId = "0199b200-0000-7000-8000-000000000003";
|
||||
const templateId = "0199b200-0000-7000-8000-000000000004";
|
||||
client.setQueryData(resourceKeys.company(company.id), { resources: [], references: [] });
|
||||
client.setQueryData(resourceCatalogKeys.scope(company.id), {
|
||||
items: [
|
||||
{
|
||||
id: addressId,
|
||||
builtinKey: "field:email",
|
||||
effectiveActive: true,
|
||||
values: { kind: "field", name: "Dirección de correo", type: "text", unit: "", choices: [] },
|
||||
},
|
||||
{
|
||||
id: contextId,
|
||||
builtinKey: "field:context",
|
||||
effectiveActive: true,
|
||||
values: { kind: "field", name: "Contexto", type: "text", unit: "", choices: [] },
|
||||
},
|
||||
{
|
||||
id: templateId,
|
||||
builtinKey: "template:mailbox",
|
||||
effectiveActive: true,
|
||||
values: {
|
||||
kind: "template",
|
||||
name: "Buzón de correo",
|
||||
fields: [
|
||||
{ fieldId: addressId, required: false },
|
||||
{ fieldId: contextId, required: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
client.setQueryData(mailKeys.company(company.id), { configurations: [] });
|
||||
client.setQueryData(profileKeys.company(company.id), { profiles: [] });
|
||||
client.setQueryData(mailboxKeys.company(company.id), { mailboxes: [], uses: [] });
|
||||
@ -89,15 +131,22 @@ test("correo: selectores reutilizables con altas y ayuda inmediatamente antes de
|
||||
|
||||
test("Buzones: alta en modal, cuenta opcional y ayuda al final", async () => {
|
||||
for (const language of ["es", "en"]) {
|
||||
const page = await render(language, <MailboxesPage company={company} csrfToken="test" />);
|
||||
const page = await render(
|
||||
language,
|
||||
<MailboxRelations
|
||||
company={company}
|
||||
csrfToken="test"
|
||||
mailboxId="0199b200-0000-7000-8000-000000000005"
|
||||
/>
|
||||
);
|
||||
assert.equal(page.includes("<form"), false);
|
||||
assert.ok(page.includes(language === "es" ? "Nuevo buzón" : "New mailbox"));
|
||||
assert.ok(page.includes(language === "es" ? "Vincular persona" : "Link person"));
|
||||
}
|
||||
const form = await render(
|
||||
"es",
|
||||
<MailboxForm company={company} csrfToken="test" onSaved={() => undefined} />
|
||||
);
|
||||
assert.ok(form.includes("Cuenta informática relacionada (opcional)"));
|
||||
assert.ok(form.indexOf("¿Qué es un buzón de correo?") < form.indexOf("Guardar"));
|
||||
assert.ok(form.includes("Cuenta relacionada (opcional)"));
|
||||
assert.ok(form.indexOf("¿Qué es y para qué sirve?") < form.indexOf("Guardar"));
|
||||
assert.equal(form.includes('type="password"'), false);
|
||||
});
|
||||
|
||||
@ -1,26 +1,8 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
|
||||
import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ResourceEditor } from "@metis/resources/client";
|
||||
|
||||
import type { CompanyDTO } from "../../common";
|
||||
import { type MailboxDTO, type MailboxInput, MailboxInputSchema } from "../../common/mailboxes";
|
||||
import { accessApi, accessKeys } from "../access/access-api";
|
||||
import { accessLabel } from "../access/access-labels";
|
||||
import { ConfigurationQuickEditor } from "../components/configuration-quick-editor";
|
||||
|
||||
import { mailKeys } from "./mail-api";
|
||||
import { MailFeedback } from "./mail-feedback";
|
||||
import { mailboxApi, mailboxKeys } from "./mailbox-api";
|
||||
|
||||
import type { MailboxDTO } from "../../common/mailboxes";
|
||||
/** El alta rápida de correo utiliza la misma ficha y persistencia que Recursos. */
|
||||
export function MailboxForm({
|
||||
company,
|
||||
csrfToken,
|
||||
@ -36,158 +18,15 @@ export function MailboxForm({
|
||||
allowQuickCreate?: boolean;
|
||||
initialAddress?: string;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
const prefix = useId();
|
||||
const client = useQueryClient();
|
||||
const mounted = useRef(true);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
const [quick, setQuick] = useState(false);
|
||||
const access = useQuery({
|
||||
queryKey: accessKeys.scope(company.id),
|
||||
queryFn: () => accessApi.request(company.id),
|
||||
});
|
||||
const form = useForm<MailboxInput>({
|
||||
resolver: zodResolver(MailboxInputSchema),
|
||||
defaultValues: mailbox?.values ?? {
|
||||
address: initialAddress,
|
||||
kind: "corporate",
|
||||
context: null,
|
||||
accountId: null,
|
||||
active: true,
|
||||
notes: null,
|
||||
},
|
||||
});
|
||||
const values = form.watch();
|
||||
const mutation = useMutation({
|
||||
mutationFn: (input: MailboxInput) =>
|
||||
mailboxApi.request(company.id, input, csrfToken, mailbox?.id),
|
||||
onSuccess: async (result) => {
|
||||
await Promise.all([
|
||||
client.invalidateQueries({ queryKey: mailboxKeys.company(company.id) }),
|
||||
client.invalidateQueries({ queryKey: mailKeys.company(company.id) }),
|
||||
]);
|
||||
if (mounted.current && result.savedId) {
|
||||
onSaved(result.savedId);
|
||||
}
|
||||
},
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<form className="space-y-5" onSubmit={form.handleSubmit((input) => mutation.mutate(input))}>
|
||||
<fieldset className="space-y-4" disabled={mutation.isPending}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={prefix}>{t("mail.address")}</Label>
|
||||
<Input
|
||||
id={prefix}
|
||||
maxLength={254}
|
||||
required
|
||||
type="email"
|
||||
{...form.register("address")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-kind`}>{t("mailbox.kind")}</Label>
|
||||
<SelectWithAction
|
||||
disabled={!!mailbox}
|
||||
id={`${prefix}-kind`}
|
||||
label={t("mailbox.kind")}
|
||||
onValueChange={(value) => {
|
||||
const parsed = MailboxInputSchema.shape.kind.safeParse(value);
|
||||
if (parsed.success) {
|
||||
form.setValue("kind", parsed.data);
|
||||
}
|
||||
}}
|
||||
options={MailboxInputSchema.shape.kind.options.map((kind) => ({
|
||||
value: kind,
|
||||
label: t(`mailbox.${kind}`),
|
||||
}))}
|
||||
placeholder={t("mail.choose")}
|
||||
value={values.kind}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-context`}>{t("mailbox.context")}</Label>
|
||||
<Input
|
||||
id={`${prefix}-context`}
|
||||
maxLength={255}
|
||||
onChange={(event) => form.setValue("context", event.target.value || null)}
|
||||
value={values.context ?? ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-account`}>{t("mailbox.account")}</Label>
|
||||
<SelectWithAction
|
||||
action={
|
||||
allowQuickCreate
|
||||
? { label: t("mail.newAccount"), onClick: () => setQuick(true) }
|
||||
: undefined
|
||||
}
|
||||
disabled={!access.data}
|
||||
id={`${prefix}-account`}
|
||||
label={t("mailbox.account")}
|
||||
onValueChange={(id) => form.setValue("accountId", id || null)}
|
||||
options={(access.data?.items ?? [])
|
||||
.filter((item) => item.values.kind === "account")
|
||||
.map((item) => ({
|
||||
value: item.id,
|
||||
label: accessLabel(item, access.data?.items ?? [], company.people),
|
||||
}))}
|
||||
placeholder={t("mailbox.noAccount")}
|
||||
value={values.accountId ?? ""}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" {...form.register("active")} />
|
||||
{t("mailbox.active")}
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-notes`}>{t("mail.notes")}</Label>
|
||||
<Textarea
|
||||
id={`${prefix}-notes`}
|
||||
maxLength={4000}
|
||||
onChange={(event) => form.setValue("notes", event.target.value || null)}
|
||||
value={values.notes ?? ""}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
{Object.keys(form.formState.errors).length > 0 && (
|
||||
<p role="alert">{t("mail.errors.INVALID_REQUEST")}</p>
|
||||
)}
|
||||
<MailFeedback error={mutation.error ?? access.error} />
|
||||
<ContextHelp
|
||||
description={t("mailbox.description")}
|
||||
examples={t("mailbox.examples")}
|
||||
examplesLabel={t("mail.examplesLabel")}
|
||||
guidance={t("mailbox.guidance")}
|
||||
title={t("mailbox.help")}
|
||||
/>
|
||||
<FormActions>
|
||||
<Button disabled={mutation.isPending || !access.data} type="submit">
|
||||
{t(mutation.isPending ? "saving" : "save")}
|
||||
</Button>
|
||||
</FormActions>
|
||||
</form>
|
||||
{quick && (
|
||||
<ConfigurationQuickEditor
|
||||
company={company}
|
||||
csrfToken={csrfToken}
|
||||
data={access.data}
|
||||
kind="account"
|
||||
onClose={() => setQuick(false)}
|
||||
onCreated={(_field, id) => {
|
||||
if (mounted.current && id) {
|
||||
form.setValue("accountId", id);
|
||||
setQuick(false);
|
||||
}
|
||||
}}
|
||||
values={{ personId: "", accountId: "" }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<ResourceEditor
|
||||
allowQuickCreate={allowQuickCreate}
|
||||
companyId={company.id}
|
||||
csrfToken={csrfToken}
|
||||
initialAddress={initialAddress}
|
||||
initialKind="mailbox"
|
||||
onSaved={onSaved}
|
||||
resourceId={mailbox?.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
|
||||
@ -11,7 +10,9 @@ import { useTranslation } from "react-i18next";
|
||||
import type { CompanyDTO } from "../../common";
|
||||
import { accessApi, accessKeys } from "../access/access-api";
|
||||
import { accessLabel } from "../access/access-labels";
|
||||
import { FolderMappingsPage } from "../folders/folder-mappings-page";
|
||||
import { MailConfigurationsPage } from "../mail/mail-configurations-page";
|
||||
import { PrinterConfigurationsPage } from "../printers/printer-configurations-page";
|
||||
|
||||
import { ProfileFeedback } from "./profile-feedback";
|
||||
import { profileApi, profileKeys } from "./work-profile-api";
|
||||
@ -25,8 +26,10 @@ export function WorkProfilesPage({
|
||||
csrfToken: string;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("companies");
|
||||
const [folderProfile,setFolderProfile]=useState<string>();
|
||||
const [mailProfile, setMailProfile] = useState<string>();
|
||||
const [configuration, setConfiguration] = useState<{
|
||||
kind: "folders" | "mail" | "printers";
|
||||
profileId: string;
|
||||
}>();
|
||||
const [editor, setEditor] = useState<string>();
|
||||
const [view, setView] = useState<string>();
|
||||
const [search, setSearch] = useState("");
|
||||
@ -63,20 +66,14 @@ export function WorkProfilesPage({
|
||||
)
|
||||
);
|
||||
const ready = [query.data, inventory.data, access.data].every(Boolean);
|
||||
if(folderProfile){return <div className="space-y-4"><Button variant="outline" onClick={()=>setFolderProfile(undefined)}>{t("profiles.title")}</Button>
|
||||
<FolderMappingsPage company={company} csrfToken={csrfToken} initialProfileId={folderProfile} /></div>;}
|
||||
if (mailProfile) {
|
||||
if (configuration) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button onClick={() => setMailProfile(undefined)} variant="outline">
|
||||
{t("profiles.title")}
|
||||
</Button>
|
||||
<MailConfigurationsPage
|
||||
company={company}
|
||||
csrfToken={csrfToken}
|
||||
initialProfileId={mailProfile}
|
||||
/>
|
||||
</div>
|
||||
<ProfileConfigurations
|
||||
company={company}
|
||||
csrfToken={csrfToken}
|
||||
onBack={() => setConfiguration(undefined)}
|
||||
selection={configuration}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
@ -141,8 +138,25 @@ export function WorkProfilesPage({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={()=>setFolderProfile(item.id)} size="sm" variant="outline">{t("folders.title")}</Button>
|
||||
<Button onClick={() => setMailProfile(item.id)} size="sm" variant="outline">
|
||||
<Button
|
||||
onClick={() => setConfiguration({ kind: "printers", profileId: item.id })}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t("printers.title")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setConfiguration({ kind: "folders", profileId: item.id })}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t("folders.title")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setConfiguration({ kind: "mail", profileId: item.id })}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t("mail.title")}
|
||||
</Button>
|
||||
<Button onClick={() => setView(item.id)} size="sm" variant="outline">
|
||||
@ -241,3 +255,28 @@ export function WorkProfilesPage({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileConfigurations({
|
||||
company,
|
||||
csrfToken,
|
||||
selection,
|
||||
onBack,
|
||||
}: {
|
||||
company: CompanyDTO;
|
||||
csrfToken: string;
|
||||
selection: { kind: "folders" | "mail" | "printers"; profileId: string };
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
const props = { company, csrfToken, initialProfileId: selection.profileId };
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button onClick={onBack} variant="outline">
|
||||
{t("profiles.title")}
|
||||
</Button>
|
||||
{selection.kind === "folders" && <FolderMappingsPage {...props} />}
|
||||
{selection.kind === "mail" && <MailConfigurationsPage {...props} />}
|
||||
{selection.kind === "printers" && <PrinterConfigurationsPage {...props} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -12,6 +12,7 @@ export function buildCatalogResponse(
|
||||
id: item.id.toPrimitive(),
|
||||
companyId: item.companyId.getOrUndefined()?.toPrimitive() ?? null,
|
||||
builtin: item.stockKey.getOrUndefined() !== undefined,
|
||||
builtinKey: item.stockKey.getOrUndefined() ?? null,
|
||||
active: item.active,
|
||||
effectiveActive: catalog.active(item, scope),
|
||||
overridden: catalog.availability.some(
|
||||
|
||||
@ -17,44 +17,126 @@ export function mapResourceInput(
|
||||
if (previous && previous.values.toPrimitive().templateId !== input.templateId.toLowerCase()) {
|
||||
return Result.fail(new ResourceCatalogError("TEMPLATE_FIXED"));
|
||||
}
|
||||
let specification = previous?.specification;
|
||||
if (!specification) {
|
||||
const template = context.catalog
|
||||
.visible(Maybe.some(companyId))
|
||||
.find((item) => item.id.toPrimitive() === input.templateId.toLowerCase());
|
||||
if (
|
||||
!template ||
|
||||
template.values.data.kind !== "template" ||
|
||||
!context.catalog.active(template, Maybe.some(companyId))
|
||||
) {
|
||||
return Result.fail(new ResourceCatalogError("TEMPLATE_UNAVAILABLE"));
|
||||
}
|
||||
const fields = template.values.data.fields.map((binding) => {
|
||||
const field = context.catalog.definitions.find(
|
||||
(item) => item.id.toPrimitive() === binding.fieldId
|
||||
);
|
||||
if (!field || field.values.data.kind !== "field") {
|
||||
throw new Error("Plantilla persistida inconsistente.");
|
||||
}
|
||||
return {
|
||||
fieldId: field.id.toPrimitive(),
|
||||
name: field.values.data.name,
|
||||
type: field.values.data.type,
|
||||
unit: field.values.data.unit,
|
||||
choices: [...field.values.data.choices],
|
||||
required: binding.required,
|
||||
};
|
||||
});
|
||||
const created = ResourceSpecification.create({
|
||||
templateId: template.id.toPrimitive(),
|
||||
templateName: template.values.data.name,
|
||||
fields,
|
||||
});
|
||||
if (!created.isSuccess) {
|
||||
return created;
|
||||
}
|
||||
specification = created.data;
|
||||
const captured = previous
|
||||
? Result.ok(previous.specification)
|
||||
: captureSpecification(input, companyId, context);
|
||||
if (!captured.isSuccess) {
|
||||
return captured;
|
||||
}
|
||||
const values = ResourceValues.create(input, specification);
|
||||
const specification = captured.data;
|
||||
const compatibleInput = withMailboxIntegration(input, context);
|
||||
const templateKey = context.catalog.definitions
|
||||
.find((item) => item.id.toPrimitive() === input.templateId.toLowerCase())
|
||||
?.stockKey.getOrUndefined();
|
||||
if (templateKey === "template:access" && compatibleInput.integration?.kind !== "access") {
|
||||
return Result.fail(new ResourceCatalogError("REFERENCE_UNAVAILABLE"));
|
||||
}
|
||||
const references = validateReferences(compatibleInput, context);
|
||||
if (!references.isSuccess) {
|
||||
return references;
|
||||
}
|
||||
const values = ResourceValues.create(compatibleInput, specification);
|
||||
return values.isSuccess ? Result.ok({ specification, values: values.data }) : values;
|
||||
}
|
||||
|
||||
function captureSpecification(
|
||||
input: ResourceInput,
|
||||
companyId: UniqueID,
|
||||
context: InventoryContext
|
||||
) {
|
||||
const template = context.catalog
|
||||
.visible(Maybe.some(companyId))
|
||||
.find((item) => item.id.toPrimitive() === input.templateId.toLowerCase());
|
||||
if (
|
||||
!template ||
|
||||
template.values.data.kind !== "template" ||
|
||||
!context.catalog.active(template, Maybe.some(companyId))
|
||||
) {
|
||||
return Result.fail(new ResourceCatalogError("TEMPLATE_UNAVAILABLE"));
|
||||
}
|
||||
const fields = template.values.data.fields.map((binding) => {
|
||||
const field = context.catalog.definitions.find(
|
||||
(item) => item.id.toPrimitive() === binding.fieldId
|
||||
);
|
||||
if (!field || field.values.data.kind !== "field") {
|
||||
throw new Error("Plantilla persistida inconsistente.");
|
||||
}
|
||||
return {
|
||||
fieldId: field.id.toPrimitive(),
|
||||
name: field.values.data.name,
|
||||
type: field.values.data.type,
|
||||
unit: field.values.data.unit,
|
||||
choices: [...field.values.data.choices],
|
||||
required: binding.required,
|
||||
};
|
||||
});
|
||||
const created = ResourceSpecification.create({
|
||||
templateId: template.id.toPrimitive(),
|
||||
templateName: template.values.data.name,
|
||||
fields,
|
||||
});
|
||||
if (!created.isSuccess) {
|
||||
return created;
|
||||
}
|
||||
return created;
|
||||
}
|
||||
function withMailboxIntegration(input: ResourceInput, context: InventoryContext): ResourceInput {
|
||||
const compatibleInput = { ...input };
|
||||
if (!compatibleInput.integration) {
|
||||
const template = context.catalog.definitions.find(
|
||||
(item) => item.id.toPrimitive() === input.templateId.toLowerCase()
|
||||
);
|
||||
if (template?.stockKey.getOrUndefined() === "template:mailbox") {
|
||||
const addressFieldId = context.catalog.definitions
|
||||
.find((item) => item.stockKey.getOrUndefined() === "field:email")
|
||||
?.id.toPrimitive();
|
||||
const contextFieldId = context.catalog.definitions
|
||||
.find((item) => item.stockKey.getOrUndefined() === "field:context")
|
||||
?.id.toPrimitive();
|
||||
if (!(addressFieldId && contextFieldId)) {
|
||||
throw new Error("Campos de buzón ausentes.");
|
||||
}
|
||||
compatibleInput.integration = {
|
||||
kind: "mailbox",
|
||||
addressFieldId,
|
||||
contextFieldId,
|
||||
mailboxKind: "corporate",
|
||||
accountId: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
return compatibleInput;
|
||||
}
|
||||
function validateReferences(compatibleInput: ResourceInput, context: InventoryContext) {
|
||||
const integration = compatibleInput.integration;
|
||||
if (integration) {
|
||||
const expected =
|
||||
integration.kind === "mailbox"
|
||||
? ([[integration.accountId, "account"]] as const)
|
||||
: ([
|
||||
[integration.resourceTypeId, "resource_type"],
|
||||
[integration.systemId, "system"],
|
||||
[integration.environmentId, "environment"],
|
||||
] as const);
|
||||
for (const [id, kind] of expected) {
|
||||
if (
|
||||
id &&
|
||||
!context.references.some((item) => item.id === id.toLowerCase() && item.kind === kind)
|
||||
) {
|
||||
return Result.fail(new ResourceCatalogError("REFERENCE_UNAVAILABLE"));
|
||||
}
|
||||
}
|
||||
if (
|
||||
integration.kind === "access" &&
|
||||
integration.environmentId &&
|
||||
!context.references.some(
|
||||
(item) =>
|
||||
item.id === integration.environmentId?.toLowerCase() &&
|
||||
item.systemId === integration.systemId
|
||||
)
|
||||
) {
|
||||
return Result.fail(new ResourceCatalogError("REFERENCE_UNAVAILABLE"));
|
||||
}
|
||||
}
|
||||
return Result.ok(true);
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import type { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { ResourceReferenceDTO } from "../../common/inventory";
|
||||
import type { Resource } from "../domain/resource.aggregate";
|
||||
import type { ResourceCatalog } from "../domain/resource-catalog.aggregate";
|
||||
import type { ResourceCatalogError } from "../domain/resource-definition";
|
||||
@ -9,6 +10,7 @@ export type InventoryContext = Readonly<{
|
||||
catalog: ResourceCatalog;
|
||||
resources: readonly Resource[];
|
||||
actorNames: ReadonlyMap<string, string>;
|
||||
references: readonly ResourceReferenceDTO[];
|
||||
}>;
|
||||
export interface IResourceRepository {
|
||||
get(companyId: UniqueID): Promise<Result<InventoryContext, ResourceCatalogError>>;
|
||||
|
||||
@ -34,11 +34,29 @@ export class ResourceUseCases {
|
||||
if (!mapped.isSuccess) {
|
||||
return Result.fail(mapped.error);
|
||||
}
|
||||
const current = mapped.data.values.toPrimitive();
|
||||
if (
|
||||
current.integration?.kind === "mailbox" &&
|
||||
context.resources.some((item) => {
|
||||
const old = item.values.toPrimitive();
|
||||
return (
|
||||
!item.id.equals(previous?.id) &&
|
||||
old.integration?.kind === "mailbox" &&
|
||||
old.characteristics[old.integration.addressFieldId] ===
|
||||
current.characteristics[
|
||||
current.integration?.kind === "mailbox" ? current.integration.addressFieldId : ""
|
||||
]
|
||||
);
|
||||
})
|
||||
) {
|
||||
return Result.fail(new ResourceCatalogError("NAME_EXISTS"));
|
||||
}
|
||||
if (
|
||||
context.resources.some(
|
||||
(item) =>
|
||||
!item.id.equals(previous?.id) &&
|
||||
item.values.toPrimitive().templateId === mapped.data.values.toPrimitive().templateId &&
|
||||
sameAccessContext(item.values.toPrimitive().integration, current.integration) &&
|
||||
item.values.name.toPrimitive().toLocaleLowerCase("es") ===
|
||||
mapped.data.values.name.toPrimitive().toLocaleLowerCase("es")
|
||||
)
|
||||
@ -58,3 +76,17 @@ export class ResourceUseCases {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function sameAccessContext(
|
||||
first: ResourceInput["integration"],
|
||||
second: ResourceInput["integration"]
|
||||
) {
|
||||
if (first?.kind !== "access" || second?.kind !== "access") {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
first.resourceTypeId === second.resourceTypeId &&
|
||||
first.systemId === second.systemId &&
|
||||
first.environmentId === second.environmentId
|
||||
);
|
||||
}
|
||||
|
||||
@ -15,9 +15,11 @@ const FIELDS: readonly [string, string, ResourceFieldType][] = [
|
||||
["expires", "Caducidad", "date"],
|
||||
["fingerprint", "Huella digital", "text"],
|
||||
["purpose", "Finalidad", "text"],
|
||||
["reference", "Referencia", "text"],
|
||||
];
|
||||
const TEMPLATES: readonly [string, string, readonly string[]][] = [
|
||||
["mailbox", "Buzón de correo", ["email", "provider", "context"]],
|
||||
["access", "Recurso de acceso", ["reference"]],
|
||||
["shared_folder", "Carpeta compartida", ["unc", "server", "description"]],
|
||||
["application", "Aplicación", ["provider", "url", "description"]],
|
||||
["certificate", "Certificado digital", ["holder", "issuer", "expires", "fingerprint", "purpose"]],
|
||||
|
||||
@ -24,7 +24,7 @@ const field = {
|
||||
} as const;
|
||||
test("catálogo de recursos: serie idempotente, UUID v7, características opcionales y protección", () => {
|
||||
const catalog = seedResourceCatalog(ResourceCatalog.create());
|
||||
assert.equal(catalog.definitions.length, 16);
|
||||
assert.equal(catalog.definitions.length, 18);
|
||||
assert.equal(seedResourceCatalog(catalog), catalog);
|
||||
assert.ok(catalog.definitions.every((item) => item.id.toPrimitive()[14] === "7"));
|
||||
const template = catalog.definitions.find((item) => item.values.data.kind === "template");
|
||||
|
||||
@ -15,12 +15,30 @@ export const ResourceValueSchema = z.union([
|
||||
z.number().finite(),
|
||||
z.boolean(),
|
||||
]);
|
||||
export const ResourceIntegrationSchema = z.discriminatedUnion("kind", [
|
||||
z.strictObject({
|
||||
kind: z.literal("mailbox"),
|
||||
addressFieldId: z.uuid(),
|
||||
contextFieldId: z.uuid(),
|
||||
mailboxKind: z.enum(["corporate", "external"]),
|
||||
accountId: z.uuid().nullable(),
|
||||
}),
|
||||
z.strictObject({
|
||||
kind: z.literal("access"),
|
||||
referenceFieldId: z.uuid(),
|
||||
resourceTypeId: z.uuid(),
|
||||
systemId: z.uuid().nullable(),
|
||||
environmentId: z.uuid().nullable(),
|
||||
}),
|
||||
]);
|
||||
export type ResourceIntegration = z.infer<typeof ResourceIntegrationSchema>;
|
||||
export const ResourceInputSchema = z.strictObject({
|
||||
name: z.string().min(1).max(100),
|
||||
templateId: z.uuid(),
|
||||
active: z.boolean(),
|
||||
notes: z.string().max(10000),
|
||||
characteristics: z.record(z.uuid(), ResourceValueSchema),
|
||||
integration: ResourceIntegrationSchema.optional(),
|
||||
});
|
||||
export const ResourceSpecificationSchema = z.strictObject({
|
||||
templateId: z.uuid(),
|
||||
|
||||
@ -54,6 +54,24 @@ export class Resource extends AggregateRoot<ResourceProps> {
|
||||
if (values.toPrimitive().templateId !== this.values.toPrimitive().templateId) {
|
||||
return Result.fail(new ResourceCatalogError("TEMPLATE_FIXED"));
|
||||
}
|
||||
const previous = this.values.toPrimitive().integration;
|
||||
const next = values.toPrimitive().integration;
|
||||
if (
|
||||
previous?.kind !== next?.kind ||
|
||||
(previous?.kind === "mailbox" &&
|
||||
next?.kind === "mailbox" &&
|
||||
(previous.mailboxKind !== next.mailboxKind ||
|
||||
previous.addressFieldId !== next.addressFieldId ||
|
||||
previous.contextFieldId !== next.contextFieldId)) ||
|
||||
(previous?.kind === "access" &&
|
||||
next?.kind === "access" &&
|
||||
(previous.referenceFieldId !== next.referenceFieldId ||
|
||||
previous.resourceTypeId !== next.resourceTypeId ||
|
||||
previous.systemId !== next.systemId ||
|
||||
previous.environmentId !== next.environmentId))
|
||||
) {
|
||||
return Result.fail(new ResourceCatalogError("CONTEXT_FIXED"));
|
||||
}
|
||||
return Result.ok(
|
||||
this.values.equals(values)
|
||||
? this
|
||||
|
||||
@ -106,3 +106,76 @@ test("recurso: identidad estable, historial, desactivación e idempotencia", ()
|
||||
assert.ok(!changeTemplate.isSuccess);
|
||||
assert.equal(changeTemplate.error.code, "TEMPLATE_FIXED");
|
||||
});
|
||||
|
||||
test("buzón como recurso: correo válido, cuenta opcional, cambios auditados y tipo fijo", () => {
|
||||
const addressFieldId = ids[0];
|
||||
const contextFieldId = UniqueID.generateNewID().toPrimitive();
|
||||
assert.ok(addressFieldId);
|
||||
const spec = ResourceSpecification.create({
|
||||
templateId,
|
||||
templateName: "Buzón",
|
||||
fields: [addressFieldId, contextFieldId].map((fieldId) => ({
|
||||
fieldId,
|
||||
name: fieldId,
|
||||
type: "text" as const,
|
||||
unit: "",
|
||||
choices: [],
|
||||
required: false,
|
||||
})),
|
||||
});
|
||||
assert.ok(spec.isSuccess);
|
||||
const mailbox: ResourceInput = {
|
||||
...input({ [addressFieldId]: "ANA@EMPRESA.ES" }),
|
||||
integration: {
|
||||
kind: "mailbox",
|
||||
addressFieldId,
|
||||
contextFieldId,
|
||||
mailboxKind: "corporate",
|
||||
accountId: null,
|
||||
},
|
||||
};
|
||||
const values = ResourceValues.create(mailbox, spec.data);
|
||||
assert.ok(values.isSuccess);
|
||||
assert.equal(values.data.toPrimitive().characteristics[addressFieldId], "ana@empresa.es");
|
||||
assert.ok(Object.isFrozen(values.data.toPrimitive().integration));
|
||||
const invalid = ResourceValues.create(
|
||||
{ ...mailbox, characteristics: { [addressFieldId]: "no-es-correo" } },
|
||||
spec.data
|
||||
);
|
||||
assert.ok(!invalid.isSuccess);
|
||||
assert.equal(invalid.error.code, "INVALID_EMAIL");
|
||||
const missing = ResourceValues.create({ ...mailbox, characteristics: {} }, spec.data);
|
||||
assert.ok(!missing.isSuccess);
|
||||
assert.equal(missing.error.code, "INVALID_EMAIL");
|
||||
const actor = UniqueID.generateNewID();
|
||||
const created = Resource.create(
|
||||
UniqueID.generateNewID(),
|
||||
spec.data,
|
||||
values.data,
|
||||
actor,
|
||||
"2026-10-05T12:00:00.000Z"
|
||||
);
|
||||
assert.ok(created.isSuccess);
|
||||
assert.ok(mailbox.integration?.kind === "mailbox");
|
||||
const related = ResourceValues.create(
|
||||
{
|
||||
...mailbox,
|
||||
integration: { ...mailbox.integration, accountId: UniqueID.generateNewID().toPrimitive() },
|
||||
},
|
||||
spec.data
|
||||
);
|
||||
assert.ok(related.isSuccess);
|
||||
const updated = created.data.update(related.data, actor, "2026-10-05T13:00:00.000Z");
|
||||
assert.ok(updated.isSuccess);
|
||||
assert.equal(updated.data.history.length, 2);
|
||||
assert.equal(updated.data.history[0]?.values.toPrimitive().integration?.kind, "mailbox");
|
||||
assert.ok(updated.data.id.equals(created.data.id));
|
||||
const external = ResourceValues.create(
|
||||
{ ...mailbox, integration: { ...mailbox.integration, mailboxKind: "external" } },
|
||||
spec.data
|
||||
);
|
||||
assert.ok(external.isSuccess);
|
||||
const rejected = created.data.update(external.data, actor, "2026-10-05T14:00:00.000Z");
|
||||
assert.ok(!rejected.isSuccess);
|
||||
assert.equal(rejected.error.code, "CONTEXT_FIXED");
|
||||
});
|
||||
|
||||
@ -122,6 +122,11 @@ export class ResourceValues {
|
||||
return parsedCharacteristics;
|
||||
}
|
||||
const characteristics = parsedCharacteristics.data;
|
||||
const integration = data.integration;
|
||||
const integrationError = validateIntegration(data, characteristics, specification);
|
||||
if (integrationError) {
|
||||
return Result.fail(new ResourceCatalogError(integrationError));
|
||||
}
|
||||
return Result.ok(
|
||||
new ResourceValues(
|
||||
Object.freeze({
|
||||
@ -130,6 +135,7 @@ export class ResourceValues {
|
||||
templateId: data.templateId.toLowerCase(),
|
||||
notes: data.notes.trim(),
|
||||
characteristics: Object.freeze(characteristics),
|
||||
...(integration ? { integration: Object.freeze({ ...integration }) } : {}),
|
||||
}),
|
||||
name.data
|
||||
)
|
||||
@ -146,6 +152,7 @@ export class ResourceValues {
|
||||
first.templateId === second.templateId &&
|
||||
first.active === second.active &&
|
||||
first.notes === second.notes &&
|
||||
sameIntegration(first.integration, second.integration) &&
|
||||
Object.keys(first.characteristics).length === Object.keys(second.characteristics).length &&
|
||||
Object.entries(first.characteristics).every(
|
||||
([key, value]) => value === second.characteristics[key]
|
||||
@ -180,3 +187,82 @@ function normalizeCharacteristics(
|
||||
}
|
||||
return Result.ok(characteristics);
|
||||
}
|
||||
|
||||
function sameIntegration(
|
||||
first: ResourceInput["integration"],
|
||||
second: ResourceInput["integration"]
|
||||
) {
|
||||
if (!(first && second)) {
|
||||
return first === second;
|
||||
}
|
||||
if (first.kind === "mailbox" && second.kind === "mailbox") {
|
||||
return (
|
||||
first.addressFieldId === second.addressFieldId &&
|
||||
first.contextFieldId === second.contextFieldId &&
|
||||
first.mailboxKind === second.mailboxKind &&
|
||||
first.accountId === second.accountId
|
||||
);
|
||||
}
|
||||
return (
|
||||
first.kind === "access" &&
|
||||
second.kind === "access" &&
|
||||
first.referenceFieldId === second.referenceFieldId &&
|
||||
first.resourceTypeId === second.resourceTypeId &&
|
||||
first.systemId === second.systemId &&
|
||||
first.environmentId === second.environmentId
|
||||
);
|
||||
}
|
||||
|
||||
function validateIntegration(
|
||||
data: ResourceInput,
|
||||
characteristics: ResourceInput["characteristics"],
|
||||
specification: ResourceSpecification
|
||||
) {
|
||||
const integration = data.integration;
|
||||
if (!validIntegrationFields(integration, specification)) {
|
||||
return "INVALID_CHARACTERISTICS" as const;
|
||||
}
|
||||
|
||||
if (integration && data.notes.trim().length > 4000) {
|
||||
return "INVALID_REQUEST" as const;
|
||||
}
|
||||
if (integration?.kind === "mailbox") {
|
||||
const address = characteristics[integration.addressFieldId];
|
||||
if (typeof address !== "string" || !z.email().max(254).safeParse(address).success) {
|
||||
return "INVALID_EMAIL" as const;
|
||||
}
|
||||
characteristics[integration.addressFieldId] = address.toLowerCase();
|
||||
const context = characteristics[integration.contextFieldId];
|
||||
if (typeof context === "string" && context.length > 255) {
|
||||
return "INVALID_CHARACTERISTICS" as const;
|
||||
}
|
||||
}
|
||||
if (integration?.kind === "access") {
|
||||
const reference = characteristics[integration.referenceFieldId];
|
||||
if (typeof reference === "string" && reference.length > 255) {
|
||||
return "INVALID_CHARACTERISTICS" as const;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validIntegrationFields(
|
||||
integration: ResourceInput["integration"],
|
||||
specification: ResourceSpecification
|
||||
) {
|
||||
const fields =
|
||||
integration?.kind === "mailbox"
|
||||
? [integration.addressFieldId, integration.contextFieldId]
|
||||
: integration?.kind === "access"
|
||||
? [integration.referenceFieldId]
|
||||
: [];
|
||||
return !(
|
||||
new Set(fields).size !== fields.length ||
|
||||
fields.some(
|
||||
(id) =>
|
||||
!specification
|
||||
.toPrimitive()
|
||||
.fields.some((field) => field.fieldId === id && field.type === "text")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -35,6 +35,7 @@ export function registerResourceRoutes(
|
||||
return;
|
||||
}
|
||||
response.json({
|
||||
references: result.data.references,
|
||||
resources: result.data.resources.map((resource) =>
|
||||
buildResourceResponse(resource, result.data.actorNames)
|
||||
),
|
||||
|
||||
@ -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 type { Sequelize, Transaction } from "sequelize";
|
||||
import { QueryTypes } from "sequelize";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { ResourceReferenceSchema } from "../../../common/inventory";
|
||||
import type { IResourceRepository, InventoryContext } from "../../application/resource.repository";
|
||||
import { ResourceCatalogError } from "../../domain/resource-definition";
|
||||
|
||||
@ -14,6 +16,7 @@ import {
|
||||
ResourceDefinitionModel,
|
||||
} from "./resource-catalog.model";
|
||||
import { ResourceCatalogPersistenceMapper } from "./resource-catalog-persistence.mapper";
|
||||
import { projectResource } from "./resource-compatibility";
|
||||
import { ResourcePersistenceMapper } from "./resource-persistence.mapper";
|
||||
|
||||
export class SequelizeResourceRepository implements IResourceRepository {
|
||||
@ -100,7 +103,30 @@ export class SequelizeResourceRepository implements IResourceRepository {
|
||||
"SELECT id, name FROM users",
|
||||
{ type: QueryTypes.SELECT, transaction }
|
||||
);
|
||||
const rawReferences = await this.database.query<{ id: string; values: unknown }>(
|
||||
"SELECT i.id,i.values FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId WHERE (w.companyId=:companyId OR w.companyId IS NULL) AND i.kind IN ('account','system','environment','resource_type')",
|
||||
{ replacements: { companyId: companyId.toPrimitive() }, transaction, type: QueryTypes.SELECT }
|
||||
);
|
||||
const references = rawReferences.map((row) => {
|
||||
const data = z
|
||||
.object({
|
||||
kind: z.enum(["account", "system", "environment", "resource_type"]),
|
||||
name: z.string().optional(),
|
||||
identifier: z.string().optional(),
|
||||
active: z.boolean(),
|
||||
systemId: z.uuid().optional(),
|
||||
})
|
||||
.parse(typeof row.values === "string" ? JSON.parse(row.values) : row.values);
|
||||
return ResourceReferenceSchema.parse({
|
||||
id: row.id,
|
||||
kind: data.kind,
|
||||
name: data.name ?? data.identifier ?? "",
|
||||
active: data.active,
|
||||
systemId: data.systemId ?? null,
|
||||
});
|
||||
});
|
||||
return Result.ok({
|
||||
references,
|
||||
catalog,
|
||||
resources,
|
||||
actorNames: new Map(actors.map((actor) => [actor.id, actor.name])),
|
||||
@ -120,6 +146,7 @@ export class SequelizeResourceRepository implements IResourceRepository {
|
||||
return changed;
|
||||
}
|
||||
const persistence = this.mapper.toPersistence(changed.data);
|
||||
await projectResource(this.database, changed.data, transaction);
|
||||
await ResourceModel.upsert(persistence.resource, { transaction });
|
||||
const existing = await ResourceRevisionModel.findAll({
|
||||
where: { resourceId: persistence.resource.id },
|
||||
|
||||
@ -8,6 +8,7 @@ import { ResourceCatalogUseCases } from "./application/resource-catalog.use-case
|
||||
import { seedResourceCatalog } from "./domain/resource-catalog-seed";
|
||||
import { registerResourceRoutes } from "./infrastructure/http/resource.routes";
|
||||
import { registerResourceCatalogRoutes } from "./infrastructure/http/resource-catalog.routes";
|
||||
import { migrateLegacyResources } from "./infrastructure/persistence/migrate-legacy-resources";
|
||||
import { migrateInventory } from "./infrastructure/persistence/resource.model";
|
||||
import {
|
||||
ResourceCatalogModel,
|
||||
@ -37,6 +38,7 @@ export async function createResourceModule(database: Sequelize, authenticate: Re
|
||||
const catalog = new ResourceCatalogUseCases(repository);
|
||||
const inventory = new ResourceUseCases(new SequelizeResourceRepository(database));
|
||||
return {
|
||||
migrateLegacy: () => migrateLegacyResources(database),
|
||||
register: (app: Parameters<typeof registerResourceCatalogRoutes>[0]) => {
|
||||
registerResourceCatalogRoutes(app, catalog, authenticate);
|
||||
registerResourceRoutes(app, inventory, authenticate);
|
||||
|
||||
@ -15,6 +15,7 @@ export const CatalogItemSchema = z.strictObject({
|
||||
id: z.uuid(),
|
||||
companyId: z.uuid().nullable(),
|
||||
builtin: z.boolean(),
|
||||
builtinKey: z.string().nullable().optional(),
|
||||
active: z.boolean(),
|
||||
effectiveActive: z.boolean(),
|
||||
overridden: z.boolean(),
|
||||
|
||||
@ -26,8 +26,17 @@ export const ResourceSchema = z.strictObject({
|
||||
values: ResourceInputSchema,
|
||||
history: z.array(ResourceRevisionSchema).min(1),
|
||||
});
|
||||
export const ResourceReferenceSchema = z.strictObject({
|
||||
id: z.uuid(),
|
||||
kind: z.enum(["account", "system", "environment", "resource_type"]),
|
||||
name: z.string(),
|
||||
active: z.boolean(),
|
||||
systemId: z.uuid().nullable(),
|
||||
});
|
||||
export type ResourceReferenceDTO = z.infer<typeof ResourceReferenceSchema>;
|
||||
export const InventoryResponseSchema = z.strictObject({
|
||||
resources: z.array(ResourceSchema),
|
||||
references: z.array(ResourceReferenceSchema).optional(),
|
||||
});
|
||||
export const ResourceResponseSchema = z.strictObject({ resource: ResourceSchema });
|
||||
export type ResourceDTO = z.infer<typeof ResourceSchema>;
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
export { catalogKeys } from "./catalog-api";
|
||||
export { ResourceCatalogPage } from "./catalog-page";
|
||||
export { resourcesEnglish, resourcesSpanish } from "./i18n";
|
||||
export { inventoryKeys } from "./inventory-api";
|
||||
export { ResourceEditor } from "./resource-editor";
|
||||
export { ResourcesPage } from "./resources-page";
|
||||
|
||||
@ -1,5 +1,12 @@
|
||||
export const inventorySpanish = {
|
||||
inventory: "Recursos",
|
||||
mailboxKind: "Tipo de buzón",
|
||||
corporate: "Corporativo",
|
||||
external: "Externo",
|
||||
relatedAccount: "Cuenta relacionada (opcional)",
|
||||
resourceTypeId: "Tipo de acceso",
|
||||
systemId: "Sistema (opcional)",
|
||||
environmentId: "Entorno (opcional)",
|
||||
newResource: "Nuevo recurso",
|
||||
editResource: "Editar recurso",
|
||||
viewResource: "Ficha e historial",
|
||||
@ -26,6 +33,9 @@ export const inventorySpanish = {
|
||||
relatedNotice: "La plantilla se guardará aunque después canceles el recurso.",
|
||||
inventoryErrors: {
|
||||
INVALID_REQUEST: "Revisa los datos del formulario.",
|
||||
INVALID_EMAIL: "Introduce una dirección de correo válida.",
|
||||
CONTEXT_FIXED: "El tipo y contexto del recurso no se pueden cambiar.",
|
||||
REFERENCE_UNAVAILABLE: "Selecciona referencias válidas de esta compañía.",
|
||||
INVALID_CHARACTERISTICS:
|
||||
"Una característica no pertenece a la plantilla o tiene un valor inválido.",
|
||||
REQUIRED_CHARACTERISTICS: "Completa las características obligatorias de la plantilla.",
|
||||
@ -41,6 +51,13 @@ export const inventorySpanish = {
|
||||
};
|
||||
export const inventoryEnglish = {
|
||||
inventory: "Resources",
|
||||
mailboxKind: "Mailbox type",
|
||||
corporate: "Corporate",
|
||||
external: "External",
|
||||
relatedAccount: "Related account (optional)",
|
||||
resourceTypeId: "Access type",
|
||||
systemId: "System (optional)",
|
||||
environmentId: "Environment (optional)",
|
||||
newResource: "New resource",
|
||||
editResource: "Edit resource",
|
||||
viewResource: "Details and history",
|
||||
@ -66,6 +83,9 @@ export const inventoryEnglish = {
|
||||
relatedNotice: "The template remains saved if you later cancel the resource.",
|
||||
inventoryErrors: {
|
||||
INVALID_REQUEST: "Check the form data.",
|
||||
INVALID_EMAIL: "Enter a valid email address.",
|
||||
CONTEXT_FIXED: "The resource type and context cannot change.",
|
||||
REFERENCE_UNAVAILABLE: "Select valid references from this company.",
|
||||
INVALID_CHARACTERISTICS:
|
||||
"A characteristic is not part of the template or has an invalid value.",
|
||||
REQUIRED_CHARACTERISTICS: "Complete the required template characteristics.",
|
||||
|
||||
@ -1,8 +1,14 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { ResourceDTO } from "../common/inventory";
|
||||
import type { ResourceDTO, ResourceInput, ResourceReferenceDTO } from "../common/inventory";
|
||||
|
||||
export function ResourceDetail({ resource }: { resource: ResourceDTO }) {
|
||||
export function ResourceDetail({
|
||||
resource,
|
||||
references = [],
|
||||
}: {
|
||||
resource: ResourceDTO;
|
||||
references?: readonly ResourceReferenceDTO[];
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("resources");
|
||||
const display = (value: string | number | boolean | undefined) =>
|
||||
value === undefined
|
||||
@ -34,6 +40,7 @@ export function ResourceDetail({ resource }: { resource: ResourceDTO }) {
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<IntegrationSummary integration={resource.values.integration} references={references} />
|
||||
{resource.values.notes && (
|
||||
<p className="whitespace-pre-wrap break-words">{resource.values.notes}</p>
|
||||
)}
|
||||
@ -63,6 +70,7 @@ export function ResourceDetail({ resource }: { resource: ResourceDTO }) {
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<IntegrationSummary integration={revision.values.integration} references={references} />
|
||||
{revision.values.notes && (
|
||||
<p className="whitespace-pre-wrap break-words">{revision.values.notes}</p>
|
||||
)}
|
||||
@ -72,3 +80,46 @@ export function ResourceDetail({ resource }: { resource: ResourceDTO }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IntegrationSummary({
|
||||
integration,
|
||||
references,
|
||||
}: {
|
||||
integration: ResourceInput["integration"];
|
||||
references: readonly ResourceReferenceDTO[];
|
||||
}) {
|
||||
const { t } = useTranslation("resources");
|
||||
if (!integration) {
|
||||
return null;
|
||||
}
|
||||
const entries =
|
||||
integration.kind === "mailbox"
|
||||
? [
|
||||
{ label: t("mailboxKind"), value: t(integration.mailboxKind) },
|
||||
{
|
||||
label: t("relatedAccount"),
|
||||
value: referenceName(integration.accountId, references, t("noValue")),
|
||||
},
|
||||
]
|
||||
: (["resourceTypeId", "systemId", "environmentId"] as const).map((key) => ({
|
||||
label: t(key),
|
||||
value: referenceName(integration[key], references, t("noValue")),
|
||||
}));
|
||||
return (
|
||||
<dl className="grid gap-2 sm:grid-cols-2">
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.label}>
|
||||
<dt className="text-sm text-muted-foreground">{entry.label}</dt>
|
||||
<dd>{entry.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
function referenceName(
|
||||
id: string | null,
|
||||
references: readonly ResourceReferenceDTO[],
|
||||
missing: string
|
||||
) {
|
||||
return id ? (references.find((item) => item.id === id)?.name ?? id) : missing;
|
||||
}
|
||||
|
||||
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 { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -18,6 +18,7 @@ import { ResourceInputSchema } from "../common/inventory";
|
||||
import { CatalogTemplateForm } from "./catalog-template-form";
|
||||
import { InventoryApiError, inventoryApi, inventoryKeys } from "./inventory-api";
|
||||
import { ResourceCharacteristicControl } from "./resource-characteristic-control";
|
||||
import { ResourceIntegrationFields, mailboxIntegration } from "./resource-integration-fields";
|
||||
export function InventoryFeedback({ error }: { error: Error | null }) {
|
||||
const { t } = useTranslation("resources");
|
||||
return error ? (
|
||||
@ -33,6 +34,9 @@ export function ResourceForm({
|
||||
items,
|
||||
onSaved,
|
||||
allowQuickCreate = true,
|
||||
initialKind,
|
||||
initialAddress = "",
|
||||
initialIntegration,
|
||||
}: {
|
||||
companyId: string;
|
||||
csrfToken: string;
|
||||
@ -40,6 +44,9 @@ export function ResourceForm({
|
||||
items: readonly CatalogItemDTO[];
|
||||
onSaved: (id: string) => void;
|
||||
allowQuickCreate?: boolean;
|
||||
initialKind?: "mailbox" | "access" | undefined;
|
||||
initialAddress?: string;
|
||||
initialIntegration?: ResourceInput["integration"];
|
||||
}) {
|
||||
const { t } = useTranslation("resources");
|
||||
const prefix = useId();
|
||||
@ -52,43 +59,37 @@ export function ResourceForm({
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
const referenceQuery = useQuery({
|
||||
queryKey: inventoryKeys.company(companyId),
|
||||
queryFn: () => inventoryApi.get(companyId),
|
||||
});
|
||||
const form = useForm<ResourceInput>({
|
||||
resolver: zodResolver(ResourceInputSchema),
|
||||
defaultValues: resource?.values ?? {
|
||||
name: "",
|
||||
templateId: "",
|
||||
active: true,
|
||||
notes: "",
|
||||
characteristics: {},
|
||||
},
|
||||
defaultValues: resourceDefaults(
|
||||
items,
|
||||
resource,
|
||||
initialKind,
|
||||
initialAddress,
|
||||
initialIntegration
|
||||
),
|
||||
});
|
||||
const values = form.watch();
|
||||
const templates = items.filter((item) => item.values.kind === "template" && item.effectiveActive);
|
||||
const template = templates.find((item) => item.id === values.templateId);
|
||||
const fields =
|
||||
resource?.specification.fields ??
|
||||
(template?.values.kind === "template"
|
||||
? template.values.fields.flatMap((binding) => {
|
||||
const field = items.find((item) => item.id === binding.fieldId);
|
||||
return field?.values.kind === "field"
|
||||
? [
|
||||
{
|
||||
fieldId: field.id,
|
||||
name: field.values.name,
|
||||
type: field.values.type,
|
||||
unit: field.values.unit,
|
||||
choices: field.values.choices,
|
||||
required: binding.required,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
})
|
||||
: []);
|
||||
const fields = resourceFields(resource, template, items);
|
||||
const mutation = useMutation({
|
||||
mutationFn: (input: ResourceInput) =>
|
||||
inventoryApi.save(companyId, input, csrfToken, resource?.id),
|
||||
onSuccess: async (response) => {
|
||||
await client.invalidateQueries({ queryKey: inventoryKeys.company(companyId) });
|
||||
await Promise.all(
|
||||
[
|
||||
inventoryKeys.company(companyId),
|
||||
["mailboxes"],
|
||||
["mail-configurations"],
|
||||
["access"],
|
||||
["folder-mappings"],
|
||||
].map((queryKey) => client.invalidateQueries({ queryKey }))
|
||||
);
|
||||
if (mounted.current) {
|
||||
onSaved(response.resource.id);
|
||||
}
|
||||
@ -108,7 +109,20 @@ export function ResourceForm({
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<form className="space-y-5" onSubmit={form.handleSubmit((input) => mutation.mutate(input))}>
|
||||
<form
|
||||
className="space-y-5"
|
||||
onSubmit={form.handleSubmit((input) => {
|
||||
if (template?.builtinKey === "template:access" && input.integration?.kind !== "access") {
|
||||
form.setError("integration", { message: t("inventoryErrors.REFERENCE_UNAVAILABLE") });
|
||||
return;
|
||||
}
|
||||
const integration =
|
||||
template?.builtinKey === "template:mailbox"
|
||||
? mailboxIntegration(items, input.integration)
|
||||
: input.integration;
|
||||
mutation.mutate(integration ? { ...input, integration } : input);
|
||||
})}
|
||||
>
|
||||
<fieldset className="space-y-5" disabled={mutation.isPending}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-name`}>{t("name")}</Label>
|
||||
@ -129,6 +143,7 @@ export function ResourceForm({
|
||||
onValueChange={(templateId) => {
|
||||
form.setValue("templateId", templateId);
|
||||
form.setValue("characteristics", {});
|
||||
form.setValue("integration", undefined);
|
||||
}}
|
||||
options={
|
||||
resource
|
||||
@ -163,6 +178,16 @@ export function ResourceForm({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ResourceIntegrationFields
|
||||
fixed={!!resource}
|
||||
integration={values.integration}
|
||||
items={items}
|
||||
onChange={(integration) =>
|
||||
form.setValue("integration", integration, { shouldDirty: true })
|
||||
}
|
||||
references={referenceQuery.data?.references ?? []}
|
||||
templateKey={template?.builtinKey}
|
||||
/>
|
||||
{fields.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<h3 className="font-medium">{t("characteristics")}</h3>
|
||||
@ -170,7 +195,7 @@ export function ResourceForm({
|
||||
{fields.map((field) => (
|
||||
<ResourceCharacteristicControl
|
||||
disabled={mutation.isPending}
|
||||
field={field}
|
||||
field={requireMailboxAddress(field, items, values, template)}
|
||||
key={field.fieldId}
|
||||
onChange={(value) => setCharacteristic(field.fieldId, value)}
|
||||
prefix={prefix}
|
||||
@ -184,7 +209,9 @@ export function ResourceForm({
|
||||
<Label htmlFor={`${prefix}-notes`}>{t("notes")}</Label>
|
||||
<Textarea
|
||||
id={`${prefix}-notes`}
|
||||
maxLength={10000}
|
||||
maxLength={
|
||||
values.integration || template?.builtinKey === "template:mailbox" ? 4000 : 10000
|
||||
}
|
||||
rows={3}
|
||||
{...form.register("notes")}
|
||||
/>
|
||||
@ -195,7 +222,7 @@ export function ResourceForm({
|
||||
{t("inventoryErrors.INVALID_REQUEST")}
|
||||
</p>
|
||||
)}
|
||||
<InventoryFeedback error={mutation.error} />
|
||||
<InventoryFeedback error={mutation.error ?? referenceQuery.error} />
|
||||
<ContextHelp
|
||||
description={t("resourceDescription")}
|
||||
examples={t("resourceExamples")}
|
||||
@ -240,3 +267,72 @@ export function ResourceForm({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function resourceFields(
|
||||
resource: ResourceDTO | undefined,
|
||||
template: CatalogItemDTO | undefined,
|
||||
items: readonly CatalogItemDTO[]
|
||||
) {
|
||||
return (
|
||||
resource?.specification.fields ??
|
||||
(template?.values.kind === "template"
|
||||
? template.values.fields.flatMap((binding) => {
|
||||
const field = items.find((item) => item.id === binding.fieldId);
|
||||
return field?.values.kind === "field"
|
||||
? [
|
||||
{
|
||||
fieldId: field.id,
|
||||
name: field.values.name,
|
||||
type: field.values.type,
|
||||
unit: field.values.unit,
|
||||
choices: field.values.choices,
|
||||
required: binding.required,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
})
|
||||
: [])
|
||||
);
|
||||
}
|
||||
|
||||
function _notesLimit(values: ResourceInput, template: CatalogItemDTO | undefined) {
|
||||
return values.integration || template?.builtinKey === "template:mailbox" ? 4000 : 10000;
|
||||
}
|
||||
function requireMailboxAddress(
|
||||
field: ReturnType<typeof resourceFields>[number],
|
||||
items: readonly CatalogItemDTO[],
|
||||
values: ResourceInput,
|
||||
template: CatalogItemDTO | undefined
|
||||
) {
|
||||
return {
|
||||
...field,
|
||||
required:
|
||||
field.required ||
|
||||
(field.fieldId === mailboxIntegration(items, values.integration)?.addressFieldId &&
|
||||
template?.builtinKey === "template:mailbox"),
|
||||
};
|
||||
}
|
||||
|
||||
function resourceDefaults(
|
||||
items: readonly CatalogItemDTO[],
|
||||
resource: ResourceDTO | undefined,
|
||||
initialKind: "mailbox" | "access" | undefined,
|
||||
initialAddress: string,
|
||||
initialIntegration: ResourceInput["integration"]
|
||||
): ResourceInput {
|
||||
if (resource) {
|
||||
return resource.values;
|
||||
}
|
||||
const template = initialKind
|
||||
? items.find((item) => item.builtinKey === `template:${initialKind}`)
|
||||
: undefined;
|
||||
const addressField = items.find((item) => item.builtinKey === "field:email");
|
||||
return {
|
||||
name: initialAddress.slice(0, 100),
|
||||
templateId: template?.id ?? "",
|
||||
active: true,
|
||||
integration: initialIntegration,
|
||||
notes: "",
|
||||
characteristics: addressField && initialAddress ? { [addressField.id]: initialAddress } : {},
|
||||
};
|
||||
}
|
||||
|
||||
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 { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import { useId, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { ResourceDTO } from "../common";
|
||||
|
||||
import { catalogApi, catalogKeys } from "./catalog-api";
|
||||
import { inventoryApi, inventoryKeys } from "./inventory-api";
|
||||
import { ResourceDetail } from "./resource-detail";
|
||||
import { InventoryFeedback, ResourceForm } from "./resource-form";
|
||||
|
||||
export function ResourcesPage({ companyId, csrfToken }: { companyId: string; csrfToken: string }) {
|
||||
export function ResourcesPage({
|
||||
companyId,
|
||||
csrfToken,
|
||||
relatedContent,
|
||||
}: {
|
||||
companyId: string;
|
||||
csrfToken: string;
|
||||
relatedContent?: (resource: ResourceDTO) => ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation("resources");
|
||||
const searchId = useId();
|
||||
const [search, setSearch] = useState("");
|
||||
@ -132,7 +143,8 @@ export function ResourcesPage({ companyId, csrfToken }: { companyId: string; csr
|
||||
title={viewed.values.name}
|
||||
wide
|
||||
>
|
||||
<ResourceDetail resource={viewed} />
|
||||
<ResourceDetail references={query.data?.references ?? []} resource={viewed} />
|
||||
{relatedContent?.(viewed)}
|
||||
</FormDialog>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user