Aplicaciones, certificados y entorno actual
This commit is contained in:
parent
f957dd40cf
commit
beee755f19
214
apps/server/src/applications.integration.ts
Normal file
214
apps/server/src/applications.integration.ts
Normal file
@ -0,0 +1,214 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { InventoryResponseSchema as AssetsSchema } from "@metis/assets/common";
|
||||
import {
|
||||
type ApplicationConfigurationInput,
|
||||
ApplicationConfigurationResponseSchema,
|
||||
ApplicationConfigurationsResponseSchema,
|
||||
CompanyResponseSchema,
|
||||
WorkProfileResponseSchema,
|
||||
} from "@metis/companies/common";
|
||||
import { CatalogResponseSchema, ResourceResponseSchema } from "@metis/resources/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 verifyApplications(
|
||||
request: Request,
|
||||
companyId: string,
|
||||
otherId: string,
|
||||
database: Sequelize
|
||||
) {
|
||||
const personResponse = await request(`/companies/${companyId}/people`, {
|
||||
name: "Persona aplicaciones",
|
||||
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 assets = AssetsSchema.parse(await (await request(`/assets/${companyId}`)).json());
|
||||
const host = assets.assets.find((item) => item.values.status !== "retired");
|
||||
assert.ok(host);
|
||||
const profileValues = {
|
||||
name: "Perfil aplicaciones",
|
||||
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 catalog = CatalogResponseSchema.parse(
|
||||
await (await request(`/resource-catalog/${companyId}`)).json()
|
||||
);
|
||||
const template = catalog.items.find((item) => item.builtinKey === "template:application");
|
||||
assert.ok(template);
|
||||
assert.equal(template.values.kind, "template");
|
||||
const add = async (templateId: string, name: string) => {
|
||||
const response = await request(`/resources/${companyId}`, {
|
||||
values: { name, templateId, active: true, notes: "", characteristics: {} },
|
||||
});
|
||||
assert.equal(response.status, 201);
|
||||
return ResourceResponseSchema.parse(await response.json()).resource;
|
||||
};
|
||||
const app = await add(template.id, "Thunderbird prueba");
|
||||
const certificateTemplate = catalog.items.find(
|
||||
(item) => item.builtinKey === "template:certificate"
|
||||
);
|
||||
assert.ok(certificateTemplate);
|
||||
const certificate = await add(certificateTemplate.id, "Certificado prueba aplicaciones");
|
||||
assert.ok(template.values.kind === "template");
|
||||
const customValues = {
|
||||
...template.values,
|
||||
name: "Software personalizado",
|
||||
usage: "application" as const,
|
||||
};
|
||||
const customResponse = await request(`/resource-catalog/${companyId}`, { values: customValues });
|
||||
assert.equal(customResponse.status, 201);
|
||||
const custom = CatalogResponseSchema.parse(
|
||||
await (await request(`/resource-catalog/${companyId}`)).json()
|
||||
).items.find((item) => item.values.name === customValues.name);
|
||||
assert.ok(custom);
|
||||
const customApp = await add(custom.id, "ERP navegador prueba");
|
||||
const immutable = await request(`/resource-catalog/${companyId}/${custom.id}/update`, {
|
||||
values: { ...customValues, usage: "certificate" },
|
||||
});
|
||||
assert.equal(immutable.status, 409);
|
||||
const path = `/application-configurations/${companyId}`;
|
||||
const input: ApplicationConfigurationInput = {
|
||||
profileId: profile.id,
|
||||
resourceId: app.id,
|
||||
delivery: "installed",
|
||||
version: null,
|
||||
state: "pending",
|
||||
notes: null,
|
||||
};
|
||||
const save = async (values: ApplicationConfigurationInput, id?: string) => {
|
||||
const response = await request(path + (id ? `/${id}/update` : ""), { values });
|
||||
assert.equal(response.status, id ? 200 : 201);
|
||||
return ApplicationConfigurationResponseSchema.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, resourceId: randomUUID() },
|
||||
"APPLICATION_REFERENCE_NOT_FOUND",
|
||||
undefined,
|
||||
404
|
||||
);
|
||||
await error(
|
||||
{ ...input, resourceId: certificate.id },
|
||||
"APPLICATION_REFERENCE_NOT_FOUND",
|
||||
undefined,
|
||||
404
|
||||
);
|
||||
await error({ ...input, delivery: "web", version: "1" }, "INVALID_REQUEST", undefined, 400);
|
||||
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((r) => r.status).sort(), [201, 409]);
|
||||
const response = attempts.find((r) => r.status === 201);
|
||||
assert.ok(response);
|
||||
const created = ApplicationConfigurationResponseSchema.parse(await response.json()).configuration;
|
||||
assert.equal(created.id[14], "7");
|
||||
assert.equal(created.history[0]?.id[14], "7");
|
||||
assert.deepEqual(await save(input, created.id), created);
|
||||
const configured = await save(
|
||||
{ ...input, state: "configured", version: "128", notes: "Solicitado por Ana" },
|
||||
created.id
|
||||
);
|
||||
assert.equal(configured.history.length, 2);
|
||||
assert.equal(configured.history[0]?.values.version, null);
|
||||
await save({ ...input, resourceId: customApp.id, delivery: "web", state: "configured" });
|
||||
await error({ ...input, resourceId: customApp.id }, "APPLICATION_TARGET_IMMUTABLE", created.id);
|
||||
await error({ ...input, state: "removed" }, "INVALID_APPLICATION_TRANSITION", created.id);
|
||||
await database.query("UPDATE application_configurations SET currentKey=NULL WHERE id=:id", {
|
||||
replacements: { id: created.id },
|
||||
});
|
||||
try {
|
||||
assert.equal((await request(path)).status, 500);
|
||||
} finally {
|
||||
await database.query("UPDATE application_configurations SET currentKey=:key WHERE id=:id", {
|
||||
replacements: { id: created.id, key: `${profile.id}:${app.id}` },
|
||||
});
|
||||
}
|
||||
const deactivate = await request(`/resources/${companyId}/${app.id}/update`, {
|
||||
values: { ...app.values, active: false },
|
||||
});
|
||||
assert.equal(deactivate.status, 200);
|
||||
const list = ApplicationConfigurationsResponseSchema.parse(
|
||||
await (await request(path)).json()
|
||||
).configurations;
|
||||
assert.equal(list.find((item) => item.id === created.id)?.needsReview, true);
|
||||
await error(
|
||||
{ ...configured.values, version: "129" },
|
||||
"APPLICATION_REFERENCE_UNAVAILABLE",
|
||||
created.id
|
||||
);
|
||||
const noted = await save({ ...configured.values, notes: "Retirar al salir" }, created.id);
|
||||
assert.equal(noted.history.length, 3);
|
||||
const removal = await save({ ...noted.values, state: "removal_pending" }, created.id);
|
||||
const removed = await save({ ...removal.values, state: "removed" }, created.id);
|
||||
assert.equal(removed.needsReview, false);
|
||||
await error({ ...removed.values, notes: "Otra" }, "INVALID_APPLICATION_TRANSITION", created.id);
|
||||
assert.equal(
|
||||
(
|
||||
await request(`/application-configurations/${otherId}/${created.id}/update`, {
|
||||
values: input,
|
||||
})
|
||||
).status,
|
||||
404
|
||||
);
|
||||
assert.equal(
|
||||
ApplicationConfigurationsResponseSchema.parse(
|
||||
await (await request(`/application-configurations/${otherId}`)).json()
|
||||
).configurations.length,
|
||||
0
|
||||
);
|
||||
// Un recurso compartido por perfiles mantiene configuraciones independientes.
|
||||
const secondPersonResponse = await request(`/companies/${companyId}/people`, {
|
||||
name: "Otra persona aplicaciones",
|
||||
position: null,
|
||||
email: null,
|
||||
phone: null,
|
||||
active: true,
|
||||
isContact: false,
|
||||
});
|
||||
const secondPerson = CompanyResponseSchema.parse(
|
||||
await secondPersonResponse.json()
|
||||
).company.people.at(-1);
|
||||
assert.ok(secondPerson);
|
||||
const secondProfile = await request(`/work-profiles/${companyId}`, {
|
||||
values: { ...profileValues, name: "Otro perfil aplicaciones", personId: secondPerson.id },
|
||||
});
|
||||
assert.equal(secondProfile.status, 201);
|
||||
const secondId = WorkProfileResponseSchema.parse(await secondProfile.json()).profile.id;
|
||||
await save({ ...input, profileId: secondId, resourceId: customApp.id, delivery: "web" });
|
||||
const archive = await request(`/work-profiles/${companyId}/${secondId}/update`, {
|
||||
values: {
|
||||
...profileValues,
|
||||
name: "Otro perfil aplicaciones",
|
||||
personId: secondPerson.id,
|
||||
active: false,
|
||||
},
|
||||
});
|
||||
assert.equal(archive.status, 200);
|
||||
const reviewed = ApplicationConfigurationsResponseSchema.parse(
|
||||
await (await request(path)).json()
|
||||
).configurations;
|
||||
assert.equal(reviewed.find((item) => item.values.profileId === secondId)?.needsReview, true);
|
||||
}
|
||||
230
apps/server/src/certificates.integration.ts
Normal file
230
apps/server/src/certificates.integration.ts
Normal file
@ -0,0 +1,230 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { InventoryResponseSchema as AssetsSchema } from "@metis/assets/common";
|
||||
import {
|
||||
type CertificateConfigurationInput,
|
||||
CertificateConfigurationResponseSchema,
|
||||
CertificateConfigurationsResponseSchema,
|
||||
CompanyResponseSchema,
|
||||
WorkProfileResponseSchema,
|
||||
} from "@metis/companies/common";
|
||||
import { CatalogResponseSchema, ResourceResponseSchema } from "@metis/resources/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 verifyCertificates(
|
||||
request: Request,
|
||||
companyId: string,
|
||||
otherId: string,
|
||||
database: Sequelize
|
||||
) {
|
||||
const personResponse = await request(`/companies/${companyId}/people`, {
|
||||
name: "Persona certificados",
|
||||
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 assets = AssetsSchema.parse(await (await request(`/assets/${companyId}`)).json());
|
||||
const host = assets.assets.find((item) => item.values.status !== "retired");
|
||||
assert.ok(host);
|
||||
const profileValues = {
|
||||
name: "Perfil certificados",
|
||||
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 catalog = CatalogResponseSchema.parse(
|
||||
await (await request(`/resource-catalog/${companyId}`)).json()
|
||||
);
|
||||
const template = catalog.items.find((item) => item.builtinKey === "template:certificate");
|
||||
assert.ok(template);
|
||||
assert.equal(template.values.kind, "template");
|
||||
const add = async (templateId: string, name: string) => {
|
||||
const response = await request(`/resources/${companyId}`, {
|
||||
values: { name, templateId, active: true, notes: "", characteristics: {} },
|
||||
});
|
||||
assert.equal(response.status, 201);
|
||||
return ResourceResponseSchema.parse(await response.json()).resource;
|
||||
};
|
||||
const app = await add(template.id, "Certificado empresa prueba");
|
||||
const certificateTemplate = catalog.items.find(
|
||||
(item) => item.builtinKey === "template:application"
|
||||
);
|
||||
assert.ok(certificateTemplate);
|
||||
const certificate = await add(certificateTemplate.id, "Aplicación ajena al certificado");
|
||||
assert.ok(template.values.kind === "template");
|
||||
const customValues = {
|
||||
...template.values,
|
||||
name: "Certificados personalizados",
|
||||
usage: "certificate" as const,
|
||||
};
|
||||
const customResponse = await request(`/resource-catalog/${companyId}`, { values: customValues });
|
||||
assert.equal(customResponse.status, 201);
|
||||
const custom = CatalogResponseSchema.parse(
|
||||
await (await request(`/resource-catalog/${companyId}`)).json()
|
||||
).items.find((item) => item.values.name === customValues.name);
|
||||
assert.ok(custom);
|
||||
const customApp = await add(custom.id, "Certificado colaboración prueba");
|
||||
const immutable = await request(`/resource-catalog/${companyId}/${custom.id}/update`, {
|
||||
values: { ...customValues, usage: "application" },
|
||||
});
|
||||
assert.equal(immutable.status, 409);
|
||||
const path = `/certificate-configurations/${companyId}`;
|
||||
const input: CertificateConfigurationInput = {
|
||||
profileId: profile.id,
|
||||
resourceId: app.id,
|
||||
location: null,
|
||||
state: "pending",
|
||||
notes: null,
|
||||
};
|
||||
const save = async (values: CertificateConfigurationInput, id?: string) => {
|
||||
const response = await request(path + (id ? `/${id}/update` : ""), { values });
|
||||
assert.equal(response.status, id ? 200 : 201);
|
||||
return CertificateConfigurationResponseSchema.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, resourceId: randomUUID() },
|
||||
"CERTIFICATE_REFERENCE_NOT_FOUND",
|
||||
undefined,
|
||||
404
|
||||
);
|
||||
await error(
|
||||
{ ...input, resourceId: certificate.id },
|
||||
"CERTIFICATE_REFERENCE_NOT_FOUND",
|
||||
undefined,
|
||||
404
|
||||
);
|
||||
await error({ ...input, privateKey: "forbidden" }, "INVALID_REQUEST", undefined, 400);
|
||||
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((r) => r.status).sort(), [201, 409]);
|
||||
const response = attempts.find((r) => r.status === 201);
|
||||
assert.ok(response);
|
||||
const created = CertificateConfigurationResponseSchema.parse(await response.json()).configuration;
|
||||
assert.equal(created.id[14], "7");
|
||||
assert.equal(created.history[0]?.id[14], "7");
|
||||
assert.deepEqual(await save(input, created.id), created);
|
||||
const configured = await save(
|
||||
{
|
||||
...input,
|
||||
state: "configured",
|
||||
location: "Windows del usuario",
|
||||
notes: "Necesario para firmar",
|
||||
},
|
||||
created.id
|
||||
);
|
||||
assert.equal(configured.history.length, 2);
|
||||
assert.equal(configured.history[0]?.values.location, null);
|
||||
await save({ ...input, resourceId: customApp.id, location: "Firefox", state: "configured" });
|
||||
await error({ ...input, resourceId: customApp.id }, "CERTIFICATE_TARGET_IMMUTABLE", created.id);
|
||||
await error({ ...input, state: "removed" }, "INVALID_CERTIFICATE_TRANSITION", created.id);
|
||||
await database.query("UPDATE certificate_configurations SET currentKey=NULL WHERE id=:id", {
|
||||
replacements: { id: created.id },
|
||||
});
|
||||
try {
|
||||
assert.equal((await request(path)).status, 500);
|
||||
} finally {
|
||||
await database.query("UPDATE certificate_configurations SET currentKey=:key WHERE id=:id", {
|
||||
replacements: { id: created.id, key: `${profile.id}:${app.id}` },
|
||||
});
|
||||
}
|
||||
const expiry = catalog.items.find((item) => item.builtinKey === "field:expires");
|
||||
assert.ok(expiry);
|
||||
const expiredResource = await request(`/resources/${companyId}/${app.id}/update`, {
|
||||
values: {
|
||||
...app.values,
|
||||
characteristics: { ...app.values.characteristics, [expiry.id]: "2000-01-01" },
|
||||
},
|
||||
});
|
||||
assert.equal(expiredResource.status, 200);
|
||||
const list = CertificateConfigurationsResponseSchema.parse(
|
||||
await (await request(path)).json()
|
||||
).configurations;
|
||||
assert.equal(list.find((item) => item.id === created.id)?.needsReview, true);
|
||||
assert.equal(list.find((item) => item.id === created.id)?.expired, true);
|
||||
assert.equal(list.find((item) => item.id === created.id)?.expiresOn, "2000-01-01");
|
||||
await error(
|
||||
{ ...configured.values, location: "Otro almacén" },
|
||||
"CERTIFICATE_REFERENCE_UNAVAILABLE",
|
||||
created.id
|
||||
);
|
||||
const noted = await save({ ...configured.values, notes: "Retirar al salir" }, created.id);
|
||||
assert.equal(noted.history.length, 3);
|
||||
const removal = await save({ ...noted.values, state: "removal_pending" }, created.id);
|
||||
const removed = await save({ ...removal.values, state: "removed" }, created.id);
|
||||
assert.equal(removed.needsReview, false);
|
||||
await error({ ...removed.values, notes: "Otra" }, "INVALID_CERTIFICATE_TRANSITION", created.id);
|
||||
assert.equal(
|
||||
(
|
||||
await request(`/certificate-configurations/${otherId}/${created.id}/update`, {
|
||||
values: input,
|
||||
})
|
||||
).status,
|
||||
404
|
||||
);
|
||||
assert.equal(
|
||||
CertificateConfigurationsResponseSchema.parse(
|
||||
await (await request(`/certificate-configurations/${otherId}`)).json()
|
||||
).configurations.length,
|
||||
0
|
||||
);
|
||||
// Un recurso compartido por perfiles mantiene configuraciones independientes.
|
||||
const secondPersonResponse = await request(`/companies/${companyId}/people`, {
|
||||
name: "Otra persona certificados",
|
||||
position: null,
|
||||
email: null,
|
||||
phone: null,
|
||||
active: true,
|
||||
isContact: false,
|
||||
});
|
||||
const secondPerson = CompanyResponseSchema.parse(
|
||||
await secondPersonResponse.json()
|
||||
).company.people.at(-1);
|
||||
assert.ok(secondPerson);
|
||||
const secondProfile = await request(`/work-profiles/${companyId}`, {
|
||||
values: { ...profileValues, name: "Otro perfil certificados", personId: secondPerson.id },
|
||||
});
|
||||
assert.equal(secondProfile.status, 201);
|
||||
const secondId = WorkProfileResponseSchema.parse(await secondProfile.json()).profile.id;
|
||||
await save({
|
||||
...input,
|
||||
profileId: secondId,
|
||||
resourceId: customApp.id,
|
||||
location: "Tarjeta criptográfica",
|
||||
});
|
||||
const archive = await request(`/work-profiles/${companyId}/${secondId}/update`, {
|
||||
values: {
|
||||
...profileValues,
|
||||
name: "Otro perfil certificados",
|
||||
personId: secondPerson.id,
|
||||
active: false,
|
||||
},
|
||||
});
|
||||
assert.equal(archive.status, 200);
|
||||
const reviewed = CertificateConfigurationsResponseSchema.parse(
|
||||
await (await request(path)).json()
|
||||
).configurations;
|
||||
assert.equal(reviewed.find((item) => item.values.profileId === secondId)?.needsReview, true);
|
||||
}
|
||||
@ -21,7 +21,9 @@ import { z } from "zod/v4";
|
||||
|
||||
import { verifyAccess } from "./access.integration";
|
||||
import { createApp } from "./app";
|
||||
import { verifyApplications } from "./applications.integration";
|
||||
import { verifyAssets } from "./assets.integration";
|
||||
import { verifyCertificates } from "./certificates.integration";
|
||||
import { createDatabase, readDatabaseEnvironment } from "./config/database";
|
||||
import { createIdentityRuntime } from "./infrastructure/http/identity-runtime";
|
||||
import { verifyInventory } from "./inventory.integration";
|
||||
@ -56,6 +58,10 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
|
||||
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 application_configuration_revisions");
|
||||
await database.query("DELETE FROM application_configurations");
|
||||
await database.query("DELETE FROM certificate_configuration_revisions");
|
||||
await database.query("DELETE FROM certificate_configurations");
|
||||
await database.query("DELETE FROM work_profile_revisions");
|
||||
await database.query("DELETE FROM work_profiles");
|
||||
await database.query("DELETE FROM workstation_assets");
|
||||
@ -757,6 +763,18 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
|
||||
secondCompany.id,
|
||||
database
|
||||
);
|
||||
await verifyApplications(
|
||||
(...args) => operator.request(...args),
|
||||
customer.id,
|
||||
secondCompany.id,
|
||||
database
|
||||
);
|
||||
await verifyCertificates(
|
||||
(...args) => operator.request(...args),
|
||||
customer.id,
|
||||
secondCompany.id,
|
||||
database
|
||||
);
|
||||
await verifyPrinters(
|
||||
(...args) => operator.request(...args),
|
||||
customer.id,
|
||||
|
||||
@ -24,7 +24,10 @@ export async function createIdentityRuntime(
|
||||
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);
|
||||
const profiles = await companies.initializeProfiles(
|
||||
assets.services.inventory,
|
||||
resources.services.inventory
|
||||
);
|
||||
await resources.migrateLegacy();
|
||||
const SequelizeStore = connectSequelize(session.Store);
|
||||
const store = new SequelizeStore({
|
||||
|
||||
52
docs/aplicaciones-del-perfil.md
Normal file
52
docs/aplicaciones-del-perfil.md
Normal file
@ -0,0 +1,52 @@
|
||||
# Aplicaciones del perfil
|
||||
|
||||
## Uso
|
||||
|
||||
Aplicaciones del perfil está disponible en la compañía y desde cada perfil de trabajo. El listado permite buscar por aplicación, perfil, persona y equipo. El alta y la edición se abren mediante modal; la ayuda aparece inmediatamente antes de Guardar y Cancelar.
|
||||
|
||||
La aplicación es un Resource de la compañía. Se seleccionan recursos de la plantilla de serie Aplicación o de una plantilla personalizada con uso Aplicación. El nombre no determina el tipo. Las copias de las plantillas de aplicación y certificado heredan su uso; las plantillas nuevas permiten elegirlo. El uso queda fijo tras el alta, igual que el tipo de un campo. Los recursos existentes y sus especificaciones no se reescriben.
|
||||
|
||||
Una configuración relaciona un perfil y un recurso, ambos fijos. No exige cuenta informática ni licencia. Una aplicación puede utilizarse en varios perfiles, con estados y versiones independientes. Un perfil puede necesitar varias aplicaciones. No hay duplicados abiertos de la misma pareja.
|
||||
|
||||
## Necesidad y disponibilidad
|
||||
|
||||
Pendiente de configurar documenta una necesidad. Configurado documenta que se ha comprobado su disponibilidad. Se usa un único registro con historial para seguir la preparación y la retirada, sin mantener dos listados de necesidades y configuraciones que puedan divergir.
|
||||
|
||||
Forma de uso distingue aplicación instalada en el equipo y acceso web. Versión es opcional y solo se admite en una aplicación instalada. Cambiar a acceso web vacía la versión explícitamente en el formulario. Notas es opcional, hasta 4.000 caracteres: por ejemplo, Thunderbird solicitado por Ana.
|
||||
|
||||
Proveedor, URL y descripción pertenecen al recurso común. Forma de uso, versión y estado pertenecen al perfil. Registrar una aplicación como instalada en un perfil no afirma que se haya instalado exclusivamente para ese usuario ni crea otra instalación por cada perfil. Tampoco instala software, concede permisos, almacena credenciales o consume licencias.
|
||||
|
||||
## Estados e historial
|
||||
|
||||
- Pendiente → Configurado o Nunca configurado.
|
||||
- Configurado → Pendiente de retirar.
|
||||
- Pendiente de retirar → Retirado.
|
||||
- Retirado y Nunca configurado son terminales y se conservan sin edición.
|
||||
- En estados abiertos pueden cambiar forma de uso, versión y notas, con historial.
|
||||
- Una pareja cerrada permite crear una nueva configuración con otro UUID.
|
||||
|
||||
Crear o reconfigurar en estado pendiente/configurado exige perfil y persona activos, equipo no retirado y recurso activo de la compañía. Si se archiva el perfil o se desactiva la persona/recurso, la configuración abierta muestra revisión. Sigue siendo posible añadir notas y registrar la retirada. Desactivar una plantilla no invalida sus recursos existentes.
|
||||
|
||||
Los selectores Perfil y Aplicación permiten altas rápidas con SelectWithAction. En la consulta filtrada el perfil queda fijado. El alta rápida de aplicación reutiliza el editor de recursos, preselecciona Aplicación y permite sus plantillas personalizadas. No abre un tercer modal. Cancelar el hijo conserva el padre; un hijo ya guardado permanece aunque se cancele el padre.
|
||||
|
||||
## Arquitectura y API
|
||||
|
||||
ApplicationConfiguration es AggregateRoot; ApplicationConfigurationRevision es DomainEntity con UUID propio; ApplicationConfigurationValues es Value Object inmutable con UniqueID y Maybe. Las identidades nuevas son UUID v7. Una edición semánticamente equivalente no escribe una revisión. La rehidratación comprueba identidad, pareja fija, estados, orden, fechas, clave abierta y coherencia de la última revisión.
|
||||
|
||||
application_configurations y application_configuration_revisions se crean con migración explícita e idempotente. Las claves foráneas apuntan a compañía, perfil, Resource y actor. La clave nullable de pareja abierta impide duplicados. Las escrituras bloquean la compañía y guardan raíz y revisiones nuevas en la misma transacción.
|
||||
|
||||
Companies consume un servicio público de Resources para consultar identidad, estado y uso del recurso; no importa sus modelos ORM. El servicio reconoce las plantillas de serie por clave estable y las propias por uso explícito. Los catálogos de activos y recursos siguen separados.
|
||||
|
||||
GET /api/application-configurations/:companyId lista configuraciones y avisos. POST en esa ruta crea; POST /api/application-configurations/:companyId/:id/update edita. Sesión obligatoria, origen y CSRF en escrituras, actor procedente de sesión y entrada estricta.
|
||||
|
||||
## Validación
|
||||
|
||||
Pruebas de dominio: contratos, ausencia y límites, versión solo instalada, UUID, igualdad semántica, historial, destino fijo, ciclo de retirada, ámbito, clasificación y disponibilidad, rehidratación corrupta.
|
||||
|
||||
Pruebas HTTP con MariaDB de test: plantillas de serie y personalizadas, rechazo de certificados, concurrencia, idempotencia, versiones auditadas, aislamiento entre compañías, origen/CSRF, corrupción de clave abierta, desactivación del recurso, archivo del perfil y aplicaciones compartidas entre perfiles.
|
||||
|
||||
Pruebas de interfaz: traducciones es/en, listado sin formularios, altas rápidas y ayuda inferior. Comprobación en navegador del modal principal, selección de perfil, cambio instalada/web y alta rápida de recurso, sin guardar registros ficticios en desarrollo.
|
||||
|
||||
Los [certificados por perfil](certificados-del-perfil.md) están implementados; la ficha [Entorno actual](entorno-actual.md) también está implementada.
|
||||
|
||||
Comprobaciones de esta entrega: 109 pruebas pasan, incluida una segunda ejecución completa contra MariaDB de test. Typecheck y build pasan; los 42 archivos de código y configuración modificados pasan Biome. Lint y check globales mantienen errores anteriores de carpetas (32 y 90 respectivamente), sin errores nuevos en este bloque. En navegador se verifica también el perfil fijo, ambas altas rápidas, conservación al cancelar y limpieza de versión al elegir acceso web.
|
||||
@ -66,3 +66,11 @@ PrinterConfiguration es la raíz de una impresora configurada en un perfil y Pri
|
||||
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.
|
||||
|
||||
## Aplicaciones por perfil
|
||||
|
||||
ApplicationConfiguration es AggregateRoot; ApplicationConfigurationRevision es DomainEntity con UUID propio. ApplicationConfigurationValues es Value Object inmutable con UniqueID para perfil/recurso y Maybe para versión/notas. Crear genera UUID v7, rehidratar conserva identidad e historial y editar compara semánticamente antes de añadir revisión.
|
||||
|
||||
## Certificados por perfil
|
||||
|
||||
CertificateConfiguration es AggregateRoot; CertificateConfigurationRevision es DomainEntity con UUID propio. CertificateConfigurationValues es Value Object inmutable con UniqueID para perfil/recurso y Maybe para ubicación/notas. Crear genera UUID v7, rehidratar conserva identidad e historial y editar compara semánticamente antes de añadir revisión. La caducidad se consulta a través del servicio público de Resources, sin duplicarla en el agregado.
|
||||
|
||||
61
docs/certificados-del-perfil.md
Normal file
61
docs/certificados-del-perfil.md
Normal file
@ -0,0 +1,61 @@
|
||||
# Certificados del perfil
|
||||
|
||||
## Uso
|
||||
|
||||
Certificados del perfil está disponible en la compañía y desde cada perfil. Permite buscar por certificado, perfil, persona y equipo. Alta y edición se abren mediante modal, con ayuda inmediatamente antes de Guardar y Cancelar.
|
||||
|
||||
El certificado es un Resource de la compañía, creado con la plantilla de serie Certificado digital o una plantilla propia con uso Certificado digital. El nombre no determina el tipo. Titular, emisor, huella digital, finalidad y caducidad pertenecen al recurso común. Los campos siguen siendo opcionales por defecto.
|
||||
|
||||
La configuración enlaza un perfil y un recurso, ambos fijos. Estado y notas describen su preparación y retirada. Almacén o ubicación es texto opcional, hasta 255 caracteres: Windows del usuario, Firefox, tarjeta criptográfica o varias ubicaciones indicadas conjuntamente. No se exige conocer el almacén para registrar la necesidad. No hay campos para archivos, contraseñas, PIN o claves privadas.
|
||||
|
||||
Un certificado puede usarse en varios perfiles y un perfil puede necesitar varios certificados. Cada configuración conserva su propio historial. No puede haber dos configuraciones abiertas para la misma pareja.
|
||||
|
||||
## Necesidad, configuración y retirada
|
||||
|
||||
Pendiente de configurar documenta lo que la persona necesita; Configurado documenta su disponibilidad comprobada. Se conserva un único registro con historial, sin duplicar necesidades y configuraciones.
|
||||
|
||||
Estados:
|
||||
- Pendiente → Configurado o Nunca configurado.
|
||||
- Configurado → Pendiente de retirar.
|
||||
- Pendiente de retirar → Retirado.
|
||||
- Retirado y Nunca configurado son terminales e inmutables; se permite repetir una edición equivalente.
|
||||
- Cerrar una pareja permite registrar otra configuración con nuevo UUID.
|
||||
- Ubicación y notas pueden editarse en estados abiertos.
|
||||
|
||||
Crear o reconfigurar en estados pendiente/configurado exige perfil y persona activos, equipo no retirado y certificado activo y no caducado. Si una referencia deja de estar disponible, la configuración abierta muestra revisión. Se permite añadir notas y documentar la retirada aun con referencias inactivas o caducadas.
|
||||
|
||||
Esta configuración registra información para el técnico: no instala certificados, concede autorizaciones, elimina certificados del ordenador ni retira permisos reales.
|
||||
|
||||
## Caducidad
|
||||
|
||||
Se consulta el campo global de serie Caducidad mediante su UUID y clave estable, sin depender de su nombre. Funciona en la plantilla de serie y en plantillas personalizadas que reutilizan ese campo. No se interpretan otros campos de fecha como caducidad solo por llamarse igual.
|
||||
|
||||
Una fecha ausente no impide configurar. La fecha indicada es inclusiva: se considera caducado a partir del día siguiente, con día UTC como criterio común del servidor. La fecha y el indicador caducado se calculan al consultar la configuración y no se almacenan de nuevo. Cambiar la caducidad en el recurso actualiza todas sus configuraciones.
|
||||
|
||||
El listado muestra caducidad cuando se conoce y avisa si ha caducado. Una configuración cerrada conserva su historial sin marcar retirada pendiente. No se incorporan todavía notificaciones programadas ni gestión de renovaciones.
|
||||
|
||||
## Altas rápidas
|
||||
|
||||
Perfil y Certificado digital reutilizan SelectWithAction. Al acceder desde un perfil, este queda fijado. El alta rápida de certificado reutiliza ResourceEditor, preselecciona Certificado digital y limita las plantillas a su uso; el formulario hijo no abre un tercer modal. Cancelar el hijo conserva el formulario principal. Los recursos o perfiles guardados permanecen aunque se cancele el principal.
|
||||
|
||||
## Arquitectura y persistencia
|
||||
|
||||
CertificateConfiguration es AggregateRoot; CertificateConfigurationRevision es DomainEntity con UUID propio; CertificateConfigurationValues es Value Object inmutable con UniqueID y Maybe para ubicación/notas. Las nuevas identidades son UUID v7. Ediciones semánticamente iguales no añaden revisiones. Create, update y rehydrate mantienen responsabilidades separadas.
|
||||
|
||||
certificate_configurations y certificate_configuration_revisions se crean con migración explícita e idempotente, claves foráneas y clave nullable única de pareja abierta. Las escrituras bloquean la compañía y guardan raíz y revisiones nuevas en una transacción. La rehidratación comprueba identidad, pareja fija, estado inicial, transiciones, fechas, orden y coherencia de última revisión y clave abierta.
|
||||
|
||||
Companies consume IResourceInventoryService para identidad, uso, disponibilidad y caducidad. No importa los modelos ORM de Resources. Los campos y plantillas de recursos siguen separados de activos.
|
||||
|
||||
GET /api/certificate-configurations/:companyId lista configuraciones, revisión y caducidad calculada. POST en esa ruta crea; POST /api/certificate-configurations/:companyId/:id/update edita. Requieren sesión; origen y CSRF protegen escrituras; el actor procede de sesión. Entrada y respuesta se validan con schemas estrictos.
|
||||
|
||||
## Pruebas
|
||||
|
||||
Dominio: ubicación opcional, normalización y límites, entrada estricta, UUID, idempotencia, historial, destino fijo, duplicados, cierre terminal, ámbito y clasificación, inactividad, caducidad inclusiva, notas y retirada del caducado, rehidratación corrupta.
|
||||
|
||||
HTTP con MariaDB de test: plantillas de serie y propias, rechazo de aplicaciones como certificados, concurrencia, idempotencia, historial de ubicación, caducidad del recurso propagada a la configuración, aislamiento, origen/CSRF, corrupción de clave abierta, configuración compartida entre perfiles y archivo del perfil.
|
||||
|
||||
Interfaz: es/en, listado sin formularios, altas rápidas, ubicación opcional y ayuda inferior. La comprobación en navegador abre el modal y las altas rápidas, verifica cancelación y el perfil fijo, sin guardar datos ficticios en desarrollo.
|
||||
|
||||
La ficha [Entorno actual](entorno-actual.md) reúne las configuraciones del perfil.
|
||||
|
||||
Comprobaciones de esta entrega: 117 pruebas pasan, incluida una segunda ejecución completa con MariaDB de test. Typecheck y build pasan. Los 64 archivos de código y configuración de los cambios locales pasan Biome. Lint y check globales mantienen los errores anteriores de carpetas (32 y 90), sin errores nuevos en este bloque.
|
||||
33
docs/entorno-actual.md
Normal file
33
docs/entorno-actual.md
Normal file
@ -0,0 +1,33 @@
|
||||
# Entorno actual
|
||||
|
||||
Ficha de consulta disponible desde «Entorno actual» en cada perfil de trabajo. Reúne la persona, el dispositivo, la cuenta de inicio de sesión y las configuraciones registradas para ese perfil.
|
||||
|
||||
## Equipo y puesto
|
||||
|
||||
Muestra las características del equipo que tienen valor, conservando valores como No o cero. Consulta los puestos activos que contienen ese dispositivo y presenta sus activos exclusivos y compartidos sin duplicarlos. No infiere asignaciones ni titularidades entre persona, perfil y puesto. Las relaciones físicas y el mapa visual permanecen en sus consultas actuales; no se incorpora un mapa nuevo.
|
||||
|
||||
## Configuraciones
|
||||
|
||||
Los cinco bloques son correos, carpetas compartidas, impresoras, aplicaciones y certificados. Cada fila conserva método, ruta, permisos documentados, conexión, modalidad, versión o ubicación según corresponda; también estado, notas y aviso de revisión.
|
||||
|
||||
Una persona puede tener varios correos y varios perfiles. La ficha muestra solamente las configuraciones del perfil seleccionado; permite cambiar a otros perfiles vigentes de esa persona. Las configuraciones retiradas o nunca configuradas quedan en las consultas de gestión e historial. Los correos antiguos sin perfil requieren vinculación explícita: se avisa de los registros del mismo dispositivo y persona, sin asignarlos automáticamente.
|
||||
|
||||
Los contadores distinguen configuradas, pendientes de configurar, pendientes de retirar y por revisar. La revisión puede coincidir con otro estado; los contadores no son categorías excluyentes. El filtro de atención muestra preparación, retirada y revisión. Una carpeta sin cobertura documentada requiere revisión. Un certificado caducado mantiene el aviso recibido del servidor.
|
||||
|
||||
«Gestionar» abre cada consulta filtrada, con sus modales habituales y acceso al historial. Volver conserva el perfil de la ficha. Un perfil archivado se identifica como tal y muestra el último estado registrado, sin presentarlo como entorno vigente. También se advierte de persona inactiva y perfil por revisar.
|
||||
|
||||
La ausencia de registros no demuestra que el equipo esté listo. No se inspeccionan automáticamente dispositivos ni se confunden configuraciones documentadas con autorizaciones.
|
||||
|
||||
## Arquitectura
|
||||
|
||||
Proyección de lectura en Companies sobre los servicios HTTP existentes de perfiles, activos, recursos, acceso y configuraciones. No crea entidades persistentes ni duplica datos. Los catálogos de activos y recursos siguen separados. Todas las referencias se filtran por compañía y perfil.
|
||||
|
||||
Las consultas reutilizan las claves e invalidaciones de cada gestión. Una carga incompleta o un error presenta carga/error y reintento; no se muestra una ficha parcial como si estuviera completa. La interfaz tiene traducciones es/en.
|
||||
|
||||
## Validación
|
||||
|
||||
Pruebas de proyección e interfaz: varios buzones, aislamiento por compañía y perfil, estados cerrados excluidos, correo antiguo sin vinculación automática, puestos vigentes y activos sin duplicar, nombre actual del recurso, valores booleanos, alertas y contadores, traducciones, carga, fallo con datos en caché, perfil inexistente y perfil archivado.
|
||||
|
||||
Comprobaciones de esta entrega: 122 pruebas pasan, incluidos los cinco casos nuevos de la ficha y la integración con MariaDB de test. Typecheck y build pasan. Los siete archivos de código y configuración de este bloque pasan Biome. Lint y check globales mantienen los errores previos (32 y 90 respectivamente), sin nuevos errores de este bloque.
|
||||
|
||||
En navegador se comprueban los datos reales del perfil, el filtro de atención, las consultas de correos y aplicaciones, el regreso al mismo perfil y el formulario modal con perfil fijo y cancelación. No se crean registros ficticios en desarrollo.
|
||||
@ -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.
|
||||
|
||||
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.
|
||||
El perfil incorpora también carpetas compartidas e [impresoras por perfil](impresoras-del-perfil.md), con acciones para abrir cada consulta filtrada. Las [aplicaciones por perfil](aplicaciones-del-perfil.md) están implementadas sobre recursos. Los [certificados por perfil](certificados-del-perfil.md) también están implementados. La ficha [Entorno actual](entorno-actual.md) reúne estas configuraciones. 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.
|
||||
|
||||
@ -41,7 +41,7 @@ Catálogo global con 30 campos y 12 plantillas de serie protegidas, cinco tipos
|
||||
|
||||
## Perfiles de trabajo: base implementada
|
||||
|
||||
Persona y dispositivo fijos, cuenta de inicio de sesión opcional, perfiles vigentes y archivados, múltiples personas/dispositivos, historial y avisos de revisión. Consultar [Perfiles de trabajo](perfiles-de-trabajo.md). La vista completa Entorno actual y sus configuraciones se incorporarán progresivamente; el mapa visual queda aplazado.
|
||||
Persona y dispositivo fijos, cuenta de inicio de sesión opcional, perfiles vigentes y archivados, múltiples personas/dispositivos, historial y avisos de revisión. Consultar [Perfiles de trabajo](perfiles-de-trabajo.md). La ficha Entorno actual reúne las configuraciones implementadas; el mapa visual queda aplazado.
|
||||
|
||||
## Buzones y correo por perfil: implementado
|
||||
|
||||
@ -55,11 +55,22 @@ Catálogo independiente de activos, 13 campos y cinco plantillas de serie, adici
|
||||
|
||||
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).
|
||||
|
||||
## Aplicaciones por perfil: implementado
|
||||
|
||||
Recursos de plantilla Aplicación o plantilla personalizada con uso Aplicación, instaladas o de acceso web, versión opcional, estados de preparación y retirada, historial y revisión. Modales y altas rápidas de recurso/perfil. Consultar [Aplicaciones del perfil](aplicaciones-del-perfil.md).
|
||||
|
||||
## Certificados por perfil: implementado
|
||||
|
||||
Recursos de plantilla Certificado digital o plantilla personalizada con ese uso, ubicación opcional, estados de preparación y retirada, historial y revisión por inactividad o caducidad. Modales y altas rápidas de recurso/perfil. Consultar [Certificados del perfil](certificados-del-perfil.md).
|
||||
|
||||
## Entorno actual: implementado
|
||||
|
||||
Ficha de consulta por perfil con equipo, características registradas, composición de los puestos vigentes, cuenta de inicio y configuraciones abiertas de correo, carpetas, impresoras, aplicaciones y certificados. Resumen de preparación, retirada y revisión; filtro de atención y acceso a cada gestión. Consultar [Entorno actual](entorno-actual.md).
|
||||
|
||||
## Siguientes entregas: orden actualizado
|
||||
|
||||
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.
|
||||
1. Teletrabajo y accesos remotos: autorización, equipo de origen, conectividad VPN y destino. Asistencia técnica remota independiente.
|
||||
2. 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.
|
||||
|
||||
|
||||
@ -12,7 +12,7 @@ Las altas y ediciones se abren en modal desde la cabecera o una acción explíci
|
||||
|
||||
Un campo representa una característica reutilizable de recursos. Admite texto, número, fecha, sí/no o lista de valores; nombre obligatorio, descripción y unidad opcionales. El tipo queda fijo tras el alta. En las listas se pueden añadir valores, pero no retirar los existentes.
|
||||
|
||||
Una plantilla reúne campos de recursos. Los campos comienzan como opcionales; en plantillas propias pueden marcarse obligatorios. Se puede retirar un campo de una plantilla sin borrar su definición.
|
||||
Una plantilla reúne campos de recursos. Las plantillas propias pueden declarar uso Aplicación o Certificado digital para sus configuraciones por perfil; General conserva el comportamiento anterior. El uso queda fijo al crearla y las copias de serie heredan su uso, sin deducirlo por nombre. Aplicaciones y certificados por perfil están implementados. La caducidad de un certificado se consulta desde el recurso, sin duplicarse en el perfil. Los campos comienzan como opcionales; en plantillas propias pueden marcarse obligatorios. Se puede retirar un campo de una plantilla sin borrar su definición.
|
||||
|
||||
Hay definiciones globales y adiciones por compañía. Una plantilla global solo admite campos globales. Una plantilla local admite campos globales y de su propia compañía. Los nombres del mismo tipo no se duplican en el catálogo visible, ignorando mayúsculas y espacios exteriores.
|
||||
|
||||
|
||||
@ -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 src/api/domain/printer-configuration.test.ts src/web/printers/printer.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 src/api/domain/application-configuration.test.ts src/web/applications/application.test.tsx src/api/domain/certificate-configuration.test.ts src/web/certificates/certificate.test.tsx src/web/profiles/work-environment.test.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/rdx-ddd": "workspace:*",
|
||||
|
||||
@ -0,0 +1,47 @@
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { ApplicationConfiguration } from "../domain/application-configuration.aggregate";
|
||||
import {
|
||||
ApplicationConfigurationError,
|
||||
type ApplicationConfigurationInput,
|
||||
} from "../domain/application-configuration-values";
|
||||
import { ApplicationConfigurationValues } from "../domain/value-objects/application-configuration-values";
|
||||
|
||||
import type { ApplicationContext } from "./application-configuration.repository";
|
||||
import { applicationAvailable, applicationReferences } from "./application-configuration-policy";
|
||||
export function mapApplicationInput(
|
||||
input: ApplicationConfigurationInput,
|
||||
context: ApplicationContext,
|
||||
previous?: ApplicationConfiguration
|
||||
) {
|
||||
const parsed = ApplicationConfigurationValues.create(input);
|
||||
if (!parsed.isSuccess) {
|
||||
return parsed;
|
||||
}
|
||||
const values = parsed.data;
|
||||
if (previous && !previous.values.sameTarget(values)) {
|
||||
return Result.fail(new ApplicationConfigurationError("APPLICATION_TARGET_IMMUTABLE"));
|
||||
}
|
||||
if (!applicationReferences(values, context)) {
|
||||
return Result.fail(new ApplicationConfigurationError("APPLICATION_REFERENCE_NOT_FOUND"));
|
||||
}
|
||||
const reconfiguring =
|
||||
!previous ||
|
||||
previous.values.state !== values.state ||
|
||||
previous.values.delivery !== values.delivery ||
|
||||
previous.values.version.getOrUndefined() !== values.version.getOrUndefined();
|
||||
if (
|
||||
["pending", "configured"].includes(values.state) &&
|
||||
reconfiguring &&
|
||||
!applicationAvailable(values, context)
|
||||
) {
|
||||
return Result.fail(new ApplicationConfigurationError("APPLICATION_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 ApplicationConfigurationError("APPLICATION_ALREADY_OPEN"));
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
import type { ApplicationConfigurationValues } from "../domain/value-objects/application-configuration-values";
|
||||
|
||||
import type { ApplicationContext } from "./application-configuration.repository";
|
||||
export function applicationReferences(
|
||||
values: ApplicationConfigurationValues,
|
||||
context: ApplicationContext
|
||||
) {
|
||||
return (
|
||||
context.profiles.some((item) => item.id.equals(values.profileId)) &&
|
||||
context.resources.some(
|
||||
(item) => item.id.equals(values.resourceId) && item.usage === "application"
|
||||
)
|
||||
);
|
||||
}
|
||||
export function applicationAvailable(
|
||||
values: ApplicationConfigurationValues,
|
||||
context: ApplicationContext
|
||||
) {
|
||||
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.resources.some(
|
||||
(item) => item.id.equals(values.resourceId) && item.active && item.usage === "application"
|
||||
)
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
import type { ApplicationConfigurationDTO } from "../../common/application-configurations";
|
||||
import type { ApplicationConfiguration } from "../domain/application-configuration.aggregate";
|
||||
|
||||
import type { ApplicationContext } from "./application-configuration.repository";
|
||||
import { applicationAvailable } from "./application-configuration-policy";
|
||||
export function buildApplicationResponse(
|
||||
item: ApplicationConfiguration,
|
||||
context: ApplicationContext
|
||||
): ApplicationConfigurationDTO {
|
||||
return {
|
||||
id: item.id.toPrimitive(),
|
||||
companyId: item.companyId.toPrimitive(),
|
||||
values: item.values.toPrimitive(),
|
||||
needsReview:
|
||||
!item.values.closed &&
|
||||
(!applicationAvailable(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,32 @@
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import type { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { ApplicationConfiguration } from "../domain/application-configuration.aggregate";
|
||||
import type { ApplicationConfigurationError } from "../domain/application-configuration-values";
|
||||
import type { WorkProfile } from "../domain/work-profile.aggregate";
|
||||
export type ApplicationContext = Readonly<{
|
||||
configurations: readonly ApplicationConfiguration[];
|
||||
profiles: readonly WorkProfile[];
|
||||
people: readonly { id: UniqueID; active: boolean }[];
|
||||
assets: readonly { id: UniqueID; available: boolean }[];
|
||||
resources: readonly {
|
||||
id: UniqueID;
|
||||
active: boolean;
|
||||
usage: "application" | "certificate" | null;
|
||||
}[];
|
||||
actors: ReadonlyMap<string, string>;
|
||||
}>;
|
||||
export interface IApplicationConfigurationRepository {
|
||||
get(company: UniqueID): Promise<Result<ApplicationContext, ApplicationConfigurationError>>;
|
||||
save(
|
||||
company: UniqueID,
|
||||
action: (
|
||||
context: ApplicationContext
|
||||
) => Result<ApplicationConfiguration, ApplicationConfigurationError>
|
||||
): Promise<
|
||||
Result<
|
||||
{ configuration: ApplicationConfiguration; context: ApplicationContext },
|
||||
ApplicationConfigurationError
|
||||
>
|
||||
>;
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import { ApplicationConfiguration } from "../domain/application-configuration.aggregate";
|
||||
import {
|
||||
ApplicationConfigurationError,
|
||||
type ApplicationConfigurationInput,
|
||||
} from "../domain/application-configuration-values";
|
||||
|
||||
import type { IApplicationConfigurationRepository } from "./application-configuration.repository";
|
||||
import { mapApplicationInput } from "./application-configuration-input.mapper";
|
||||
export class ApplicationConfigurationUseCases {
|
||||
constructor(private readonly repository: IApplicationConfigurationRepository) {}
|
||||
get(companyId: string) {
|
||||
const company = UniqueID.create(companyId);
|
||||
return company.isSuccess
|
||||
? this.repository.get(company.data)
|
||||
: Promise.resolve(Result.fail(new ApplicationConfigurationError("INVALID_REQUEST")));
|
||||
}
|
||||
save(
|
||||
companyId: string,
|
||||
id: string | undefined,
|
||||
input: ApplicationConfigurationInput,
|
||||
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 ApplicationConfigurationError("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 ApplicationConfigurationError("APPLICATION_CONFIGURATION_NOT_FOUND")
|
||||
);
|
||||
}
|
||||
const values = mapApplicationInput(input, context, previous);
|
||||
if (!values.isSuccess) {
|
||||
return values;
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
const candidate = previous
|
||||
? previous.update(values.data, actor.data, now)
|
||||
: ApplicationConfiguration.create(company.data, values.data, actor.data, now);
|
||||
if (!candidate.isSuccess) {
|
||||
return candidate;
|
||||
}
|
||||
return candidate;
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { CertificateConfiguration } from "../domain/certificate-configuration.aggregate";
|
||||
import {
|
||||
CertificateConfigurationError,
|
||||
type CertificateConfigurationInput,
|
||||
} from "../domain/certificate-configuration-values";
|
||||
import { CertificateConfigurationValues } from "../domain/value-objects/certificate-configuration-values";
|
||||
|
||||
import type { CertificateContext } from "./certificate-configuration.repository";
|
||||
import { certificateAvailable, certificateReferences } from "./certificate-configuration-policy";
|
||||
export function mapCertificateInput(
|
||||
input: CertificateConfigurationInput,
|
||||
context: CertificateContext,
|
||||
previous?: CertificateConfiguration
|
||||
) {
|
||||
const parsed = CertificateConfigurationValues.create(input);
|
||||
if (!parsed.isSuccess) {
|
||||
return parsed;
|
||||
}
|
||||
const values = parsed.data;
|
||||
if (previous && !previous.values.sameTarget(values)) {
|
||||
return Result.fail(new CertificateConfigurationError("CERTIFICATE_TARGET_IMMUTABLE"));
|
||||
}
|
||||
if (!certificateReferences(values, context)) {
|
||||
return Result.fail(new CertificateConfigurationError("CERTIFICATE_REFERENCE_NOT_FOUND"));
|
||||
}
|
||||
const reconfiguring =
|
||||
!previous ||
|
||||
previous.values.state !== values.state ||
|
||||
previous.values.location.getOrUndefined() !== values.location.getOrUndefined();
|
||||
if (
|
||||
["pending", "configured"].includes(values.state) &&
|
||||
reconfiguring &&
|
||||
!certificateAvailable(values, context)
|
||||
) {
|
||||
return Result.fail(new CertificateConfigurationError("CERTIFICATE_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 CertificateConfigurationError("CERTIFICATE_ALREADY_OPEN"));
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
import type { CertificateConfigurationValues } from "../domain/value-objects/certificate-configuration-values";
|
||||
|
||||
import type { CertificateContext } from "./certificate-configuration.repository";
|
||||
export function certificateReferences(
|
||||
values: CertificateConfigurationValues,
|
||||
context: CertificateContext
|
||||
) {
|
||||
return (
|
||||
context.profiles.some((item) => item.id.equals(values.profileId)) &&
|
||||
context.resources.some(
|
||||
(item) => item.id.equals(values.resourceId) && item.usage === "certificate"
|
||||
)
|
||||
);
|
||||
}
|
||||
export function certificateAvailable(
|
||||
values: CertificateConfigurationValues,
|
||||
context: CertificateContext
|
||||
) {
|
||||
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.resources.some(
|
||||
(item) =>
|
||||
item.id.equals(values.resourceId) &&
|
||||
item.active &&
|
||||
item.usage === "certificate" &&
|
||||
(!item.expiresOn || item.expiresOn >= context.today)
|
||||
)
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
import type { CertificateConfigurationDTO } from "../../common/certificate-configurations";
|
||||
import type { CertificateConfiguration } from "../domain/certificate-configuration.aggregate";
|
||||
|
||||
import type { CertificateContext } from "./certificate-configuration.repository";
|
||||
import { certificateAvailable } from "./certificate-configuration-policy";
|
||||
export function buildCertificateResponse(
|
||||
item: CertificateConfiguration,
|
||||
context: CertificateContext
|
||||
): CertificateConfigurationDTO {
|
||||
const resource = context.resources.find((resource) => resource.id.equals(item.values.resourceId));
|
||||
const expiresOn = resource?.expiresOn ?? null;
|
||||
return {
|
||||
id: item.id.toPrimitive(),
|
||||
companyId: item.companyId.toPrimitive(),
|
||||
values: item.values.toPrimitive(),
|
||||
expiresOn,
|
||||
expired: expiresOn !== null && expiresOn < context.today,
|
||||
needsReview:
|
||||
!item.values.closed &&
|
||||
(!certificateAvailable(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,34 @@
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import type { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { CertificateConfiguration } from "../domain/certificate-configuration.aggregate";
|
||||
import type { CertificateConfigurationError } from "../domain/certificate-configuration-values";
|
||||
import type { WorkProfile } from "../domain/work-profile.aggregate";
|
||||
export type CertificateContext = Readonly<{
|
||||
today: string;
|
||||
configurations: readonly CertificateConfiguration[];
|
||||
profiles: readonly WorkProfile[];
|
||||
people: readonly { id: UniqueID; active: boolean }[];
|
||||
assets: readonly { id: UniqueID; available: boolean }[];
|
||||
resources: readonly {
|
||||
id: UniqueID;
|
||||
active: boolean;
|
||||
usage: "application" | "certificate" | null;
|
||||
expiresOn: string | null;
|
||||
}[];
|
||||
actors: ReadonlyMap<string, string>;
|
||||
}>;
|
||||
export interface ICertificateConfigurationRepository {
|
||||
get(company: UniqueID): Promise<Result<CertificateContext, CertificateConfigurationError>>;
|
||||
save(
|
||||
company: UniqueID,
|
||||
action: (
|
||||
context: CertificateContext
|
||||
) => Result<CertificateConfiguration, CertificateConfigurationError>
|
||||
): Promise<
|
||||
Result<
|
||||
{ configuration: CertificateConfiguration; context: CertificateContext },
|
||||
CertificateConfigurationError
|
||||
>
|
||||
>;
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import { CertificateConfiguration } from "../domain/certificate-configuration.aggregate";
|
||||
import {
|
||||
CertificateConfigurationError,
|
||||
type CertificateConfigurationInput,
|
||||
} from "../domain/certificate-configuration-values";
|
||||
|
||||
import type { ICertificateConfigurationRepository } from "./certificate-configuration.repository";
|
||||
import { mapCertificateInput } from "./certificate-configuration-input.mapper";
|
||||
export class CertificateConfigurationUseCases {
|
||||
constructor(private readonly repository: ICertificateConfigurationRepository) {}
|
||||
get(companyId: string) {
|
||||
const company = UniqueID.create(companyId);
|
||||
return company.isSuccess
|
||||
? this.repository.get(company.data)
|
||||
: Promise.resolve(Result.fail(new CertificateConfigurationError("INVALID_REQUEST")));
|
||||
}
|
||||
save(
|
||||
companyId: string,
|
||||
id: string | undefined,
|
||||
input: CertificateConfigurationInput,
|
||||
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 CertificateConfigurationError("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 CertificateConfigurationError("CERTIFICATE_CONFIGURATION_NOT_FOUND")
|
||||
);
|
||||
}
|
||||
const values = mapCertificateInput(input, context, previous);
|
||||
if (!values.isSuccess) {
|
||||
return values;
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
const candidate = previous
|
||||
? previous.update(values.data, actor.data, now)
|
||||
: CertificateConfiguration.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 { ApplicationConfigurationValues } from "./value-objects/application-configuration-values";
|
||||
export type ApplicationConfigurationRevisionProps = Readonly<{
|
||||
values: ApplicationConfigurationValues;
|
||||
actorId: UniqueID;
|
||||
recordedAt: string;
|
||||
}>;
|
||||
export class ApplicationConfigurationRevision extends DomainEntity<ApplicationConfigurationRevisionProps> {
|
||||
static create(props: ApplicationConfigurationRevisionProps) {
|
||||
return new ApplicationConfigurationRevision(Object.freeze({ ...props }));
|
||||
}
|
||||
static rehydrate(props: ApplicationConfigurationRevisionProps, id: UniqueID) {
|
||||
return new ApplicationConfigurationRevision(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 ApplicationConfigurationInputSchema = z.strictObject({
|
||||
profileId: z.uuid(),
|
||||
resourceId: z.uuid(),
|
||||
delivery: z.enum(["installed", "web"]),
|
||||
version: z.string().trim().max(100).nullable(),
|
||||
state: ConfigurationSchema,
|
||||
notes: z.string().trim().max(4000).nullable(),
|
||||
});
|
||||
export type ApplicationConfigurationInput = z.infer<typeof ApplicationConfigurationInputSchema>;
|
||||
export class ApplicationConfigurationError extends Error {
|
||||
constructor(public readonly code: string) {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
export const APPLICATION_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 { ApplicationConfigurationRevision } from "./application-configuration-revision.entity";
|
||||
import {
|
||||
APPLICATION_STATE_TRANSITIONS,
|
||||
ApplicationConfigurationError,
|
||||
} from "./application-configuration-values";
|
||||
import type { ApplicationConfigurationValues } from "./value-objects/application-configuration-values";
|
||||
|
||||
type Props = Readonly<{
|
||||
companyId: UniqueID;
|
||||
values: ApplicationConfigurationValues;
|
||||
history: readonly ApplicationConfigurationRevision[];
|
||||
}>;
|
||||
export class ApplicationConfiguration extends AggregateRoot<Props> {
|
||||
private constructor(props: Props, id?: UniqueID) {
|
||||
super(Object.freeze({ ...props, history: Object.freeze([...props.history]) }), id);
|
||||
}
|
||||
static create(
|
||||
companyId: UniqueID,
|
||||
values: ApplicationConfigurationValues,
|
||||
actorId: UniqueID,
|
||||
recordedAt: string
|
||||
): Result<ApplicationConfiguration, ApplicationConfigurationError> {
|
||||
if (
|
||||
!(
|
||||
z.iso.datetime().safeParse(recordedAt).success &&
|
||||
["pending", "configured"].includes(values.state)
|
||||
)
|
||||
) {
|
||||
return Result.fail(new ApplicationConfigurationError("INVALID_APPLICATION_TRANSITION"));
|
||||
}
|
||||
return Result.ok(
|
||||
new ApplicationConfiguration({
|
||||
companyId,
|
||||
values,
|
||||
history: [ApplicationConfigurationRevision.create({ values, actorId, recordedAt })],
|
||||
})
|
||||
);
|
||||
}
|
||||
static rehydrate(props: Props, id: UniqueID) {
|
||||
return new ApplicationConfiguration(props, id);
|
||||
}
|
||||
get companyId() {
|
||||
return this.props.companyId;
|
||||
}
|
||||
get values() {
|
||||
return this.props.values;
|
||||
}
|
||||
get history() {
|
||||
return this.props.history;
|
||||
}
|
||||
update(
|
||||
values: ApplicationConfigurationValues,
|
||||
actorId: UniqueID,
|
||||
recordedAt: string
|
||||
): Result<ApplicationConfiguration, ApplicationConfigurationError> {
|
||||
if (this.values.equals(values)) {
|
||||
return Result.ok(this);
|
||||
}
|
||||
if (!this.values.sameTarget(values)) {
|
||||
return Result.fail(new ApplicationConfigurationError("APPLICATION_TARGET_IMMUTABLE"));
|
||||
}
|
||||
if (
|
||||
!APPLICATION_STATE_TRANSITIONS[this.values.state].some((state) => state === values.state) ||
|
||||
(this.values.closed && !this.values.equals(values))
|
||||
) {
|
||||
return Result.fail(new ApplicationConfigurationError("INVALID_APPLICATION_TRANSITION"));
|
||||
}
|
||||
if (
|
||||
!z.iso.datetime().safeParse(recordedAt).success ||
|
||||
recordedAt < (this.history.at(-1)?.recordedAt ?? "")
|
||||
) {
|
||||
return Result.fail(new ApplicationConfigurationError("INVALID_REQUEST"));
|
||||
}
|
||||
return Result.ok(
|
||||
new ApplicationConfiguration(
|
||||
{
|
||||
...this.props,
|
||||
values,
|
||||
history: [
|
||||
...this.history,
|
||||
ApplicationConfigurationRevision.create({ values, actorId, recordedAt }),
|
||||
],
|
||||
},
|
||||
this.id
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,227 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
|
||||
import type { ApplicationContext } from "../application/application-configuration.repository";
|
||||
import { mapApplicationInput } from "../application/application-configuration-input.mapper";
|
||||
import { buildApplicationResponse } from "../application/application-configuration-response.builder";
|
||||
import { ApplicationConfigurationPersistenceMapper } from "../infrastructure/persistence/application-configuration-persistence.mapper";
|
||||
|
||||
import { ApplicationConfiguration } from "./application-configuration.aggregate";
|
||||
import {
|
||||
type ApplicationConfigurationInput,
|
||||
ApplicationConfigurationInputSchema,
|
||||
} from "./application-configuration-values";
|
||||
import { ApplicationConfigurationValues } from "./value-objects/application-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 resource = UniqueID.generateNewID();
|
||||
const date = "2026-10-06T12:00:00.000Z";
|
||||
function fixture() {
|
||||
const value = WorkProfileValues.create({
|
||||
name: "Ana",
|
||||
personId: person.toPrimitive(),
|
||||
assetId: host.toPrimitive(),
|
||||
accountId: null,
|
||||
active: true,
|
||||
notes: null,
|
||||
});
|
||||
assert.ok(value.isSuccess);
|
||||
const profile = WorkProfile.create(company, value.data, actor, date);
|
||||
assert.ok(profile.isSuccess);
|
||||
const input: ApplicationConfigurationInput = {
|
||||
profileId: profile.data.id.toPrimitive(),
|
||||
resourceId: resource.toPrimitive(),
|
||||
delivery: "installed",
|
||||
version: null,
|
||||
state: "pending",
|
||||
notes: null,
|
||||
};
|
||||
const context: ApplicationContext = {
|
||||
profiles: [profile.data],
|
||||
configurations: [],
|
||||
people: [{ id: person, active: true }],
|
||||
assets: [{ id: host, available: true }],
|
||||
resources: [{ id: resource, active: true, usage: "application" }],
|
||||
actors: new Map([[actor.toPrimitive(), "Técnico"]]),
|
||||
};
|
||||
return { input, context };
|
||||
}
|
||||
function values(input: ApplicationConfigurationInput) {
|
||||
const value = ApplicationConfigurationValues.create(input);
|
||||
assert.ok(value.isSuccess);
|
||||
return value.data;
|
||||
}
|
||||
function create(input: ApplicationConfigurationInput) {
|
||||
const root = ApplicationConfiguration.create(company, values(input), actor, date);
|
||||
assert.ok(root.isSuccess);
|
||||
return root.data;
|
||||
}
|
||||
test("aplicaciones: contrato estricto, versión opcional y solo instalada", () => {
|
||||
const { input } = fixture();
|
||||
assert.equal(
|
||||
ApplicationConfigurationInputSchema.safeParse({ ...input, actorId: actor.toPrimitive() })
|
||||
.success,
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
ApplicationConfigurationValues.create({ ...input, delivery: "web", version: "1" }).isSuccess,
|
||||
false
|
||||
);
|
||||
assert.equal(values({ ...input, version: " ", notes: " " }).toPrimitive().version, null);
|
||||
assert.equal(values({ ...input, delivery: "web" }).toPrimitive().version, null);
|
||||
assert.equal(
|
||||
ApplicationConfigurationValues.create({ ...input, version: "a".repeat(101) }).isSuccess,
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
ApplicationConfigurationValues.create({ ...input, notes: "a".repeat(4001) }).isSuccess,
|
||||
false
|
||||
);
|
||||
});
|
||||
test("aplicaciones: UUID, idempotencia, versión auditada, destino fijo y retirada", () => {
|
||||
const { input } = fixture();
|
||||
const root = create(input);
|
||||
assert.equal(root.id.toPrimitive()[14], "7");
|
||||
assert.equal(root.history[0]?.id.toPrimitive()[14], "7");
|
||||
const same = root.update(values({ ...input, notes: " " }), actor, date);
|
||||
assert.ok(same.isSuccess);
|
||||
assert.equal(same.data, root);
|
||||
const configured = root.update(
|
||||
values({ ...input, state: "configured", version: "128" }),
|
||||
actor,
|
||||
date
|
||||
);
|
||||
assert.ok(configured.isSuccess);
|
||||
assert.equal(configured.data.history.length, 2);
|
||||
assert.equal(configured.data.history[0]?.values.version.getOrUndefined(), undefined);
|
||||
assert.equal(
|
||||
configured.data.update(
|
||||
values({ ...input, resourceId: UniqueID.generateNewID().toPrimitive() }),
|
||||
actor,
|
||||
date
|
||||
).isSuccess,
|
||||
false
|
||||
);
|
||||
const removal = configured.data.update(
|
||||
values({ ...configured.data.values.toPrimitive(), state: "removal_pending" }),
|
||||
actor,
|
||||
date
|
||||
);
|
||||
assert.ok(removal.isSuccess);
|
||||
const removed = removal.data.update(
|
||||
values({ ...removal.data.values.toPrimitive(), state: "removed" }),
|
||||
actor,
|
||||
date
|
||||
);
|
||||
assert.ok(removed.isSuccess);
|
||||
assert.equal(
|
||||
removed.data.update(
|
||||
values({ ...removed.data.values.toPrimitive(), notes: "Otra" }),
|
||||
actor,
|
||||
date
|
||||
).isSuccess,
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
root.update(values({ ...input, state: "configured" }), actor, "2026-10-05T12:00:00.000Z")
|
||||
.isSuccess,
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
ApplicationConfiguration.create(company, values({ ...input, state: "removed" }), actor, date)
|
||||
.isSuccess,
|
||||
false
|
||||
);
|
||||
});
|
||||
test("aplicaciones: ámbito y clasificación explícita, duplicados abiertos y reapertura nueva", () => {
|
||||
const { input, context } = fixture();
|
||||
const root = create(input);
|
||||
const duplicate = mapApplicationInput(input, { ...context, configurations: [root] });
|
||||
assert.ok(!duplicate.isSuccess);
|
||||
assert.equal(duplicate.error.code, "APPLICATION_ALREADY_OPEN");
|
||||
assert.equal(
|
||||
mapApplicationInput({ ...input, resourceId: host.toPrimitive() }, context).isSuccess,
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
mapApplicationInput(input, {
|
||||
...context,
|
||||
resources: [{ id: resource, active: true, usage: "certificate" }],
|
||||
}).isSuccess,
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
mapApplicationInput(input, {
|
||||
...context,
|
||||
resources: [{ id: resource, active: true, usage: null }],
|
||||
}).isSuccess,
|
||||
false
|
||||
);
|
||||
const closed = root.update(values({ ...input, state: "never_configured" }), actor, date);
|
||||
assert.ok(closed.isSuccess);
|
||||
assert.ok(mapApplicationInput(input, { ...context, configurations: [closed.data] }).isSuccess);
|
||||
});
|
||||
test("aplicaciones: inactividad conserva notas y permite retirada, impide reconfigurar", () => {
|
||||
const { input, context } = fixture();
|
||||
const root = create({ ...input, state: "configured" });
|
||||
const inactive = {
|
||||
...context,
|
||||
configurations: [root],
|
||||
resources: [{ id: resource, active: false, usage: "application" as const }],
|
||||
};
|
||||
assert.equal(buildApplicationResponse(root, inactive).needsReview, true);
|
||||
assert.ok(
|
||||
mapApplicationInput({ ...root.values.toPrimitive(), notes: "Retirar" }, inactive, root)
|
||||
.isSuccess
|
||||
);
|
||||
assert.ok(
|
||||
mapApplicationInput({ ...root.values.toPrimitive(), state: "removal_pending" }, inactive, root)
|
||||
.isSuccess
|
||||
);
|
||||
assert.equal(
|
||||
mapApplicationInput({ ...root.values.toPrimitive(), version: "2" }, inactive, root).isSuccess,
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
mapApplicationInput({ ...root.values.toPrimitive(), delivery: "web" }, inactive, root)
|
||||
.isSuccess,
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
mapApplicationInput(input, { ...context, people: [{ id: person, active: false }] }).isSuccess,
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
mapApplicationInput(input, { ...context, assets: [{ id: host, available: false }] }).isSuccess,
|
||||
false
|
||||
);
|
||||
});
|
||||
test("aplicaciones: persistencia preserva historial y rechaza claves y orden incoherentes", () => {
|
||||
const { input } = fixture();
|
||||
const root = create(input);
|
||||
const mapper = new ApplicationConfigurationPersistenceMapper();
|
||||
const record = mapper.toPersistence(root);
|
||||
const rehydrated = mapper.toDomain(record.root, record.revisions);
|
||||
assert.ok(rehydrated.id.equals(root.id));
|
||||
assert.ok(rehydrated.history[0]?.id.equals(root.history[0]?.id));
|
||||
assert.throws(() => mapper.toDomain({ ...record.root, currentKey: null }, record.revisions));
|
||||
assert.throws(() =>
|
||||
mapper.toDomain(
|
||||
record.root,
|
||||
record.revisions.map((r) => ({ ...r, position: 1 }))
|
||||
)
|
||||
);
|
||||
assert.throws(() =>
|
||||
mapper.toDomain(
|
||||
record.root,
|
||||
record.revisions.map((r) => ({ ...r, configurationId: company.toPrimitive() }))
|
||||
)
|
||||
);
|
||||
});
|
||||
@ -0,0 +1,25 @@
|
||||
import { DomainEntity, type UniqueID } from "@repo/rdx-ddd";
|
||||
|
||||
import type { CertificateConfigurationValues } from "./value-objects/certificate-configuration-values";
|
||||
export type CertificateConfigurationRevisionProps = Readonly<{
|
||||
values: CertificateConfigurationValues;
|
||||
actorId: UniqueID;
|
||||
recordedAt: string;
|
||||
}>;
|
||||
export class CertificateConfigurationRevision extends DomainEntity<CertificateConfigurationRevisionProps> {
|
||||
static create(props: CertificateConfigurationRevisionProps) {
|
||||
return new CertificateConfigurationRevision(Object.freeze({ ...props }));
|
||||
}
|
||||
static rehydrate(props: CertificateConfigurationRevisionProps, id: UniqueID) {
|
||||
return new CertificateConfigurationRevision(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,23 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { ConfigurationSchema } from "./access/access-definition";
|
||||
export const CertificateConfigurationInputSchema = z.strictObject({
|
||||
profileId: z.uuid(),
|
||||
resourceId: z.uuid(),
|
||||
location: z.string().trim().max(255).nullable(),
|
||||
state: ConfigurationSchema,
|
||||
notes: z.string().trim().max(4000).nullable(),
|
||||
});
|
||||
export type CertificateConfigurationInput = z.infer<typeof CertificateConfigurationInputSchema>;
|
||||
export class CertificateConfigurationError extends Error {
|
||||
constructor(public readonly code: string) {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
export const CERTIFICATE_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 { CertificateConfigurationRevision } from "./certificate-configuration-revision.entity";
|
||||
import {
|
||||
CERTIFICATE_STATE_TRANSITIONS,
|
||||
CertificateConfigurationError,
|
||||
} from "./certificate-configuration-values";
|
||||
import type { CertificateConfigurationValues } from "./value-objects/certificate-configuration-values";
|
||||
|
||||
type Props = Readonly<{
|
||||
companyId: UniqueID;
|
||||
values: CertificateConfigurationValues;
|
||||
history: readonly CertificateConfigurationRevision[];
|
||||
}>;
|
||||
export class CertificateConfiguration extends AggregateRoot<Props> {
|
||||
private constructor(props: Props, id?: UniqueID) {
|
||||
super(Object.freeze({ ...props, history: Object.freeze([...props.history]) }), id);
|
||||
}
|
||||
static create(
|
||||
companyId: UniqueID,
|
||||
values: CertificateConfigurationValues,
|
||||
actorId: UniqueID,
|
||||
recordedAt: string
|
||||
): Result<CertificateConfiguration, CertificateConfigurationError> {
|
||||
if (
|
||||
!(
|
||||
z.iso.datetime().safeParse(recordedAt).success &&
|
||||
["pending", "configured"].includes(values.state)
|
||||
)
|
||||
) {
|
||||
return Result.fail(new CertificateConfigurationError("INVALID_CERTIFICATE_TRANSITION"));
|
||||
}
|
||||
return Result.ok(
|
||||
new CertificateConfiguration({
|
||||
companyId,
|
||||
values,
|
||||
history: [CertificateConfigurationRevision.create({ values, actorId, recordedAt })],
|
||||
})
|
||||
);
|
||||
}
|
||||
static rehydrate(props: Props, id: UniqueID) {
|
||||
return new CertificateConfiguration(props, id);
|
||||
}
|
||||
get companyId() {
|
||||
return this.props.companyId;
|
||||
}
|
||||
get values() {
|
||||
return this.props.values;
|
||||
}
|
||||
get history() {
|
||||
return this.props.history;
|
||||
}
|
||||
update(
|
||||
values: CertificateConfigurationValues,
|
||||
actorId: UniqueID,
|
||||
recordedAt: string
|
||||
): Result<CertificateConfiguration, CertificateConfigurationError> {
|
||||
if (this.values.equals(values)) {
|
||||
return Result.ok(this);
|
||||
}
|
||||
if (!this.values.sameTarget(values)) {
|
||||
return Result.fail(new CertificateConfigurationError("CERTIFICATE_TARGET_IMMUTABLE"));
|
||||
}
|
||||
if (
|
||||
!CERTIFICATE_STATE_TRANSITIONS[this.values.state].some((state) => state === values.state) ||
|
||||
(this.values.closed && !this.values.equals(values))
|
||||
) {
|
||||
return Result.fail(new CertificateConfigurationError("INVALID_CERTIFICATE_TRANSITION"));
|
||||
}
|
||||
if (
|
||||
!z.iso.datetime().safeParse(recordedAt).success ||
|
||||
recordedAt < (this.history.at(-1)?.recordedAt ?? "")
|
||||
) {
|
||||
return Result.fail(new CertificateConfigurationError("INVALID_REQUEST"));
|
||||
}
|
||||
return Result.ok(
|
||||
new CertificateConfiguration(
|
||||
{
|
||||
...this.props,
|
||||
values,
|
||||
history: [
|
||||
...this.history,
|
||||
CertificateConfigurationRevision.create({ values, actorId, recordedAt }),
|
||||
],
|
||||
},
|
||||
this.id
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,261 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
|
||||
import type { CertificateContext } from "../application/certificate-configuration.repository";
|
||||
import { mapCertificateInput } from "../application/certificate-configuration-input.mapper";
|
||||
import { buildCertificateResponse } from "../application/certificate-configuration-response.builder";
|
||||
import { CertificateConfigurationPersistenceMapper } from "../infrastructure/persistence/certificate-configuration-persistence.mapper";
|
||||
|
||||
import { CertificateConfiguration } from "./certificate-configuration.aggregate";
|
||||
import {
|
||||
type CertificateConfigurationInput,
|
||||
CertificateConfigurationInputSchema,
|
||||
} from "./certificate-configuration-values";
|
||||
import { CertificateConfigurationValues } from "./value-objects/certificate-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 resource = UniqueID.generateNewID();
|
||||
const date = "2026-10-06T12:00:00.000Z";
|
||||
function fixture() {
|
||||
const value = WorkProfileValues.create({
|
||||
name: "Ana",
|
||||
personId: person.toPrimitive(),
|
||||
assetId: host.toPrimitive(),
|
||||
accountId: null,
|
||||
active: true,
|
||||
notes: null,
|
||||
});
|
||||
assert.ok(value.isSuccess);
|
||||
const profile = WorkProfile.create(company, value.data, actor, date);
|
||||
assert.ok(profile.isSuccess);
|
||||
const input: CertificateConfigurationInput = {
|
||||
profileId: profile.data.id.toPrimitive(),
|
||||
resourceId: resource.toPrimitive(),
|
||||
location: null,
|
||||
state: "pending",
|
||||
notes: null,
|
||||
};
|
||||
const context: CertificateContext = {
|
||||
profiles: [profile.data],
|
||||
today: "2026-10-06",
|
||||
configurations: [],
|
||||
people: [{ id: person, active: true }],
|
||||
assets: [{ id: host, available: true }],
|
||||
resources: [{ id: resource, active: true, usage: "certificate", expiresOn: null }],
|
||||
actors: new Map([[actor.toPrimitive(), "Técnico"]]),
|
||||
};
|
||||
return { input, context };
|
||||
}
|
||||
function values(input: CertificateConfigurationInput) {
|
||||
const value = CertificateConfigurationValues.create(input);
|
||||
assert.ok(value.isSuccess);
|
||||
return value.data;
|
||||
}
|
||||
function create(input: CertificateConfigurationInput) {
|
||||
const root = CertificateConfiguration.create(company, values(input), actor, date);
|
||||
assert.ok(root.isSuccess);
|
||||
return root.data;
|
||||
}
|
||||
test("certificados: contrato estricto, ubicación opcional y límites", () => {
|
||||
const { input } = fixture();
|
||||
assert.equal(
|
||||
CertificateConfigurationInputSchema.safeParse({ ...input, actorId: actor.toPrimitive() })
|
||||
.success,
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
CertificateConfigurationInputSchema.safeParse({ ...input, privateKey: "secret" }).success,
|
||||
false
|
||||
);
|
||||
assert.equal(values({ ...input, location: " ", notes: " " }).toPrimitive().location, null);
|
||||
assert.equal(values({ ...input, location: " Firefox " }).toPrimitive().location, "Firefox");
|
||||
assert.equal(
|
||||
CertificateConfigurationValues.create({ ...input, location: "a".repeat(256) }).isSuccess,
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
CertificateConfigurationValues.create({ ...input, notes: "a".repeat(4001) }).isSuccess,
|
||||
false
|
||||
);
|
||||
});
|
||||
test("certificados: UUID, idempotencia, ubicación auditada, destino fijo y retirada", () => {
|
||||
const { input } = fixture();
|
||||
const root = create(input);
|
||||
assert.equal(root.id.toPrimitive()[14], "7");
|
||||
assert.equal(root.history[0]?.id.toPrimitive()[14], "7");
|
||||
const same = root.update(values({ ...input, notes: " " }), actor, date);
|
||||
assert.ok(same.isSuccess);
|
||||
assert.equal(same.data, root);
|
||||
const configured = root.update(
|
||||
values({ ...input, state: "configured", location: "Windows" }),
|
||||
actor,
|
||||
date
|
||||
);
|
||||
assert.ok(configured.isSuccess);
|
||||
assert.equal(configured.data.history.length, 2);
|
||||
assert.equal(configured.data.history[0]?.values.location.getOrUndefined(), undefined);
|
||||
assert.equal(
|
||||
configured.data.update(
|
||||
values({ ...input, resourceId: UniqueID.generateNewID().toPrimitive() }),
|
||||
actor,
|
||||
date
|
||||
).isSuccess,
|
||||
false
|
||||
);
|
||||
const removal = configured.data.update(
|
||||
values({ ...configured.data.values.toPrimitive(), state: "removal_pending" }),
|
||||
actor,
|
||||
date
|
||||
);
|
||||
assert.ok(removal.isSuccess);
|
||||
const removed = removal.data.update(
|
||||
values({ ...removal.data.values.toPrimitive(), state: "removed" }),
|
||||
actor,
|
||||
date
|
||||
);
|
||||
assert.ok(removed.isSuccess);
|
||||
assert.equal(
|
||||
removed.data.update(
|
||||
values({ ...removed.data.values.toPrimitive(), notes: "Otra" }),
|
||||
actor,
|
||||
date
|
||||
).isSuccess,
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
root.update(values({ ...input, state: "configured" }), actor, "2026-10-05T12:00:00.000Z")
|
||||
.isSuccess,
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
CertificateConfiguration.create(company, values({ ...input, state: "removed" }), actor, date)
|
||||
.isSuccess,
|
||||
false
|
||||
);
|
||||
});
|
||||
test("certificados: ámbito y clasificación explícita, duplicados abiertos y reapertura nueva", () => {
|
||||
const { input, context } = fixture();
|
||||
const root = create(input);
|
||||
const duplicate = mapCertificateInput(input, { ...context, configurations: [root] });
|
||||
assert.ok(!duplicate.isSuccess);
|
||||
assert.equal(duplicate.error.code, "CERTIFICATE_ALREADY_OPEN");
|
||||
assert.equal(
|
||||
mapCertificateInput({ ...input, resourceId: host.toPrimitive() }, context).isSuccess,
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
mapCertificateInput(input, {
|
||||
...context,
|
||||
resources: [{ id: resource, active: true, usage: "application", expiresOn: null }],
|
||||
}).isSuccess,
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
mapCertificateInput(input, {
|
||||
...context,
|
||||
resources: [{ id: resource, active: true, usage: null, expiresOn: null }],
|
||||
}).isSuccess,
|
||||
false
|
||||
);
|
||||
const closed = root.update(values({ ...input, state: "never_configured" }), actor, date);
|
||||
assert.ok(closed.isSuccess);
|
||||
assert.ok(mapCertificateInput(input, { ...context, configurations: [closed.data] }).isSuccess);
|
||||
});
|
||||
test("certificados: inactividad conserva notas y permite retirada, impide reconfigurar", () => {
|
||||
const { input, context } = fixture();
|
||||
const root = create({ ...input, state: "configured" });
|
||||
const inactive = {
|
||||
...context,
|
||||
configurations: [root],
|
||||
resources: [{ id: resource, active: false, usage: "certificate" as const, expiresOn: null }],
|
||||
};
|
||||
assert.equal(buildCertificateResponse(root, inactive).needsReview, true);
|
||||
assert.ok(
|
||||
mapCertificateInput({ ...root.values.toPrimitive(), notes: "Retirar" }, inactive, root)
|
||||
.isSuccess
|
||||
);
|
||||
assert.ok(
|
||||
mapCertificateInput({ ...root.values.toPrimitive(), state: "removal_pending" }, inactive, root)
|
||||
.isSuccess
|
||||
);
|
||||
assert.equal(
|
||||
mapCertificateInput({ ...root.values.toPrimitive(), location: "Firefox" }, inactive, root)
|
||||
.isSuccess,
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
mapCertificateInput(input, { ...context, people: [{ id: person, active: false }] }).isSuccess,
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
mapCertificateInput(input, { ...context, assets: [{ id: host, available: false }] }).isSuccess,
|
||||
false
|
||||
);
|
||||
});
|
||||
test("certificados: persistencia preserva historial y rechaza claves y orden incoherentes", () => {
|
||||
const { input } = fixture();
|
||||
const root = create(input);
|
||||
const mapper = new CertificateConfigurationPersistenceMapper();
|
||||
const record = mapper.toPersistence(root);
|
||||
const rehydrated = mapper.toDomain(record.root, record.revisions);
|
||||
assert.ok(rehydrated.id.equals(root.id));
|
||||
assert.ok(rehydrated.history[0]?.id.equals(root.history[0]?.id));
|
||||
assert.throws(() => mapper.toDomain({ ...record.root, currentKey: null }, record.revisions));
|
||||
assert.throws(() =>
|
||||
mapper.toDomain(
|
||||
record.root,
|
||||
record.revisions.map((r) => ({ ...r, position: 1 }))
|
||||
)
|
||||
);
|
||||
assert.throws(() =>
|
||||
mapper.toDomain(
|
||||
record.root,
|
||||
record.revisions.map((r) => ({ ...r, configurationId: company.toPrimitive() }))
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test("certificados: caducidad inclusiva, revisión y retirada sin perder historial", () => {
|
||||
const { input, context } = fixture();
|
||||
const root = create({ ...input, state: "configured" });
|
||||
const expired = {
|
||||
...context,
|
||||
configurations: [root],
|
||||
resources: [
|
||||
{ id: resource, active: true, usage: "certificate" as const, expiresOn: "2026-10-05" },
|
||||
],
|
||||
};
|
||||
assert.equal(buildCertificateResponse(root, expired).needsReview, true);
|
||||
assert.equal(mapCertificateInput(input, expired).isSuccess, false);
|
||||
assert.ok(
|
||||
mapCertificateInput(
|
||||
{ ...root.values.toPrimitive(), notes: "Retirar certificado caducado" },
|
||||
expired,
|
||||
root
|
||||
).isSuccess
|
||||
);
|
||||
assert.ok(
|
||||
mapCertificateInput({ ...root.values.toPrimitive(), state: "removal_pending" }, expired, root)
|
||||
.isSuccess
|
||||
);
|
||||
assert.equal(
|
||||
mapCertificateInput({ ...root.values.toPrimitive(), location: "Firefox" }, expired, root)
|
||||
.isSuccess,
|
||||
false
|
||||
);
|
||||
const today = {
|
||||
...context,
|
||||
resources: [
|
||||
{ id: resource, active: true, usage: "certificate" as const, expiresOn: context.today },
|
||||
],
|
||||
};
|
||||
assert.ok(mapCertificateInput(input, today).isSuccess);
|
||||
assert.equal(buildCertificateResponse(root, today).needsReview, false);
|
||||
});
|
||||
@ -0,0 +1,69 @@
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import {
|
||||
ApplicationConfigurationError,
|
||||
type ApplicationConfigurationInput,
|
||||
ApplicationConfigurationInputSchema,
|
||||
} from "../application-configuration-values";
|
||||
export class ApplicationConfigurationValues {
|
||||
private constructor(
|
||||
public readonly profileId: UniqueID,
|
||||
public readonly resourceId: UniqueID,
|
||||
public readonly delivery: ApplicationConfigurationInput["delivery"],
|
||||
public readonly version: Maybe<string>,
|
||||
public readonly state: ApplicationConfigurationInput["state"],
|
||||
public readonly notes: Maybe<string>
|
||||
) {
|
||||
Object.freeze(this);
|
||||
}
|
||||
static create(
|
||||
input: ApplicationConfigurationInput
|
||||
): Result<ApplicationConfigurationValues, ApplicationConfigurationError> {
|
||||
const parsed = ApplicationConfigurationInputSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return Result.fail(new ApplicationConfigurationError("INVALID_REQUEST"));
|
||||
}
|
||||
const data = parsed.data;
|
||||
const profile = UniqueID.create(data.profileId);
|
||||
const resource = UniqueID.create(data.resourceId);
|
||||
if (!(profile.isSuccess && resource.isSuccess) || (data.delivery === "web" && data.version)) {
|
||||
return Result.fail(new ApplicationConfigurationError("INVALID_REQUEST"));
|
||||
}
|
||||
return Result.ok(
|
||||
new ApplicationConfigurationValues(
|
||||
profile.data,
|
||||
resource.data,
|
||||
data.delivery,
|
||||
data.version ? Maybe.some(data.version) : Maybe.none(),
|
||||
data.state,
|
||||
data.notes ? Maybe.some(data.notes) : Maybe.none()
|
||||
)
|
||||
);
|
||||
}
|
||||
get closed() {
|
||||
return this.state === "removed" || this.state === "never_configured";
|
||||
}
|
||||
sameTarget(other: ApplicationConfigurationValues) {
|
||||
return this.profileId.equals(other.profileId) && this.resourceId.equals(other.resourceId);
|
||||
}
|
||||
equals(other: ApplicationConfigurationValues) {
|
||||
return (
|
||||
this.sameTarget(other) &&
|
||||
this.delivery === other.delivery &&
|
||||
this.state === other.state &&
|
||||
this.version.getOrUndefined() === other.version.getOrUndefined() &&
|
||||
this.notes.getOrUndefined() === other.notes.getOrUndefined()
|
||||
);
|
||||
}
|
||||
toPrimitive(): ApplicationConfigurationInput {
|
||||
return {
|
||||
profileId: this.profileId.toPrimitive(),
|
||||
resourceId: this.resourceId.toPrimitive(),
|
||||
delivery: this.delivery,
|
||||
version: this.version.getOrUndefined() ?? null,
|
||||
state: this.state,
|
||||
notes: this.notes.getOrUndefined() ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import {
|
||||
CertificateConfigurationError,
|
||||
type CertificateConfigurationInput,
|
||||
CertificateConfigurationInputSchema,
|
||||
} from "../certificate-configuration-values";
|
||||
export class CertificateConfigurationValues {
|
||||
private constructor(
|
||||
public readonly profileId: UniqueID,
|
||||
public readonly resourceId: UniqueID,
|
||||
public readonly location: Maybe<string>,
|
||||
public readonly state: CertificateConfigurationInput["state"],
|
||||
public readonly notes: Maybe<string>
|
||||
) {
|
||||
Object.freeze(this);
|
||||
}
|
||||
static create(
|
||||
input: CertificateConfigurationInput
|
||||
): Result<CertificateConfigurationValues, CertificateConfigurationError> {
|
||||
const parsed = CertificateConfigurationInputSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return Result.fail(new CertificateConfigurationError("INVALID_REQUEST"));
|
||||
}
|
||||
const data = parsed.data;
|
||||
const profile = UniqueID.create(data.profileId);
|
||||
const resource = UniqueID.create(data.resourceId);
|
||||
if (!(profile.isSuccess && resource.isSuccess)) {
|
||||
return Result.fail(new CertificateConfigurationError("INVALID_REQUEST"));
|
||||
}
|
||||
return Result.ok(
|
||||
new CertificateConfigurationValues(
|
||||
profile.data,
|
||||
resource.data,
|
||||
data.location ? Maybe.some(data.location) : Maybe.none(),
|
||||
data.state,
|
||||
data.notes ? Maybe.some(data.notes) : Maybe.none()
|
||||
)
|
||||
);
|
||||
}
|
||||
get closed() {
|
||||
return this.state === "removed" || this.state === "never_configured";
|
||||
}
|
||||
sameTarget(other: CertificateConfigurationValues) {
|
||||
return this.profileId.equals(other.profileId) && this.resourceId.equals(other.resourceId);
|
||||
}
|
||||
equals(other: CertificateConfigurationValues) {
|
||||
return (
|
||||
this.sameTarget(other) &&
|
||||
this.location.getOrUndefined() === other.location.getOrUndefined() &&
|
||||
this.state === other.state &&
|
||||
this.notes.getOrUndefined() === other.notes.getOrUndefined()
|
||||
);
|
||||
}
|
||||
toPrimitive(): CertificateConfigurationInput {
|
||||
return {
|
||||
profileId: this.profileId.toPrimitive(),
|
||||
resourceId: this.resourceId.toPrimitive(),
|
||||
location: this.location.getOrUndefined() ?? null,
|
||||
state: this.state,
|
||||
notes: this.notes.getOrUndefined() ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,81 @@
|
||||
import { type Application, type RequestHandler, type Response, Router } from "express";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
ApplicationItemParamsSchema,
|
||||
ApplicationParamsSchema,
|
||||
SaveApplicationConfigurationRequestSchema,
|
||||
} from "../../../common/application-configurations";
|
||||
import type { ApplicationConfigurationUseCases } from "../../application/application-configuration.use-cases";
|
||||
import { buildApplicationResponse } from "../../application/application-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 registerApplicationConfigurationRoutes(
|
||||
app: Application,
|
||||
useCases: ApplicationConfigurationUseCases,
|
||||
authenticate: RequestHandler
|
||||
) {
|
||||
const router = Router();
|
||||
router.get("/:companyId", (request, response, next) => {
|
||||
const params = ApplicationParamsSchema.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) =>
|
||||
buildApplicationResponse(item, result.data)
|
||||
),
|
||||
});
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
for (const path of ["/:companyId", "/:companyId/:id/update"]) {
|
||||
router.post(path, (request, response, next) => {
|
||||
const params = (
|
||||
path.endsWith("update") ? ApplicationItemParamsSchema : ApplicationParamsSchema
|
||||
).safeParse(request.params);
|
||||
const body = SaveApplicationConfigurationRequestSchema.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: buildApplicationResponse(result.data.configuration, result.data.context),
|
||||
});
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
}
|
||||
app.use("/api/application-configurations", authenticate, router);
|
||||
}
|
||||
@ -0,0 +1,81 @@
|
||||
import { type Application, type RequestHandler, type Response, Router } from "express";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
CertificateItemParamsSchema,
|
||||
CertificateParamsSchema,
|
||||
SaveCertificateConfigurationRequestSchema,
|
||||
} from "../../../common/certificate-configurations";
|
||||
import type { CertificateConfigurationUseCases } from "../../application/certificate-configuration.use-cases";
|
||||
import { buildCertificateResponse } from "../../application/certificate-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 registerCertificateConfigurationRoutes(
|
||||
app: Application,
|
||||
useCases: CertificateConfigurationUseCases,
|
||||
authenticate: RequestHandler
|
||||
) {
|
||||
const router = Router();
|
||||
router.get("/:companyId", (request, response, next) => {
|
||||
const params = CertificateParamsSchema.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) =>
|
||||
buildCertificateResponse(item, result.data)
|
||||
),
|
||||
});
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
for (const path of ["/:companyId", "/:companyId/:id/update"]) {
|
||||
router.post(path, (request, response, next) => {
|
||||
const params = (
|
||||
path.endsWith("update") ? CertificateItemParamsSchema : CertificateParamsSchema
|
||||
).safeParse(request.params);
|
||||
const body = SaveCertificateConfigurationRequestSchema.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: buildCertificateResponse(result.data.configuration, result.data.context),
|
||||
});
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
}
|
||||
app.use("/api/certificate-configurations", authenticate, router);
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
import { DataTypes, QueryTypes, type Sequelize } from "sequelize";
|
||||
export async function migrateApplicationConfigurations(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 === "application_configurations")) {
|
||||
await q.createTable("application_configurations", {
|
||||
id: { type: DataTypes.UUID, primaryKey: true },
|
||||
companyId: fk("companies"),
|
||||
profileId: fk("work_profiles"),
|
||||
resourceId: fk("resources"),
|
||||
values: { type: DataTypes.JSON, allowNull: false },
|
||||
currentKey: { type: DataTypes.STRING(80), allowNull: true, unique: true },
|
||||
});
|
||||
}
|
||||
if (!tables.some((t) => t.name === "application_configuration_revisions")) {
|
||||
await q.createTable("application_configuration_revisions", {
|
||||
id: { type: DataTypes.UUID, primaryKey: true },
|
||||
configurationId: fk("application_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='application_configuration_revisions'",
|
||||
{ type: QueryTypes.SELECT }
|
||||
);
|
||||
if (!indexes.some((item) => item.name === "application_configuration_position")) {
|
||||
await q.addIndex("application_configuration_revisions", ["configurationId", "position"], {
|
||||
unique: true,
|
||||
name: "application_configuration_position",
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,119 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { ApplicationConfiguration } from "../../domain/application-configuration.aggregate";
|
||||
import { ApplicationConfigurationRevision } from "../../domain/application-configuration-revision.entity";
|
||||
import {
|
||||
APPLICATION_STATE_TRANSITIONS,
|
||||
ApplicationConfigurationInputSchema,
|
||||
} from "../../domain/application-configuration-values";
|
||||
import { ApplicationConfigurationValues } from "../../domain/value-objects/application-configuration-values";
|
||||
|
||||
import { persistedJson, profileIdentity } from "./work-profile-persistence.mapper";
|
||||
export type ApplicationRow = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
profileId: string;
|
||||
resourceId: string;
|
||||
values: unknown;
|
||||
currentKey: string | null;
|
||||
};
|
||||
export type ApplicationRevisionRow = {
|
||||
id: string;
|
||||
configurationId: string;
|
||||
position: number;
|
||||
values: unknown;
|
||||
actorId: string;
|
||||
recordedAt: string;
|
||||
};
|
||||
function mapped(raw: unknown) {
|
||||
const parsed = ApplicationConfigurationValues.create(
|
||||
ApplicationConfigurationInputSchema.parse(persistedJson(raw))
|
||||
);
|
||||
if (!parsed.isSuccess) {
|
||||
throw new Error("Valores de aplicación persistidos inconsistentes.");
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
export class ApplicationConfigurationPersistenceMapper {
|
||||
toDomain(row: ApplicationRow, revisions: readonly ApplicationRevisionRow[]) {
|
||||
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 aplicación persistido inconsistente.");
|
||||
}
|
||||
return ApplicationConfigurationRevision.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 aplicación persistido inconsistente.");
|
||||
}
|
||||
for (const [index, revision] of history.entries()) {
|
||||
const previous = history[index - 1]?.values;
|
||||
if (
|
||||
previous &&
|
||||
(!APPLICATION_STATE_TRANSITIONS[previous.state].some(
|
||||
(state) => state === revision.values.state
|
||||
) ||
|
||||
(previous.closed && !previous.equals(revision.values)))
|
||||
) {
|
||||
throw new Error("Estados de aplicación históricos inconsistentes.");
|
||||
}
|
||||
}
|
||||
const keys = applicationKeys(values);
|
||||
if (
|
||||
!history.at(-1)?.values.equals(values) ||
|
||||
row.profileId !== values.profileId.toPrimitive() ||
|
||||
row.resourceId !== values.resourceId.toPrimitive() ||
|
||||
row.currentKey !== keys.currentKey
|
||||
) {
|
||||
throw new Error("Referencias de aplicación persistidas inconsistentes.");
|
||||
}
|
||||
return ApplicationConfiguration.rehydrate(
|
||||
{ companyId: profileIdentity(row.companyId), values, history },
|
||||
profileIdentity(row.id)
|
||||
);
|
||||
}
|
||||
toPersistence(item: ApplicationConfiguration) {
|
||||
const values = item.values.toPrimitive();
|
||||
return {
|
||||
root: {
|
||||
id: item.id.toPrimitive(),
|
||||
companyId: item.companyId.toPrimitive(),
|
||||
profileId: values.profileId,
|
||||
resourceId: values.resourceId,
|
||||
values: JSON.stringify(values),
|
||||
...applicationKeys(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 applicationKeys(values: ApplicationConfigurationValues) {
|
||||
return {
|
||||
currentKey: values.closed
|
||||
? null
|
||||
: `${values.profileId.toPrimitive()}:${values.resourceId.toPrimitive()}`,
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
import { DataTypes, QueryTypes, type Sequelize } from "sequelize";
|
||||
export async function migrateCertificateConfigurations(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 === "certificate_configurations")) {
|
||||
await q.createTable("certificate_configurations", {
|
||||
id: { type: DataTypes.UUID, primaryKey: true },
|
||||
companyId: fk("companies"),
|
||||
profileId: fk("work_profiles"),
|
||||
resourceId: fk("resources"),
|
||||
values: { type: DataTypes.JSON, allowNull: false },
|
||||
currentKey: { type: DataTypes.STRING(80), allowNull: true, unique: true },
|
||||
});
|
||||
}
|
||||
if (!tables.some((t) => t.name === "certificate_configuration_revisions")) {
|
||||
await q.createTable("certificate_configuration_revisions", {
|
||||
id: { type: DataTypes.UUID, primaryKey: true },
|
||||
configurationId: fk("certificate_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='certificate_configuration_revisions'",
|
||||
{ type: QueryTypes.SELECT }
|
||||
);
|
||||
if (!indexes.some((item) => item.name === "certificate_configuration_position")) {
|
||||
await q.addIndex("certificate_configuration_revisions", ["configurationId", "position"], {
|
||||
unique: true,
|
||||
name: "certificate_configuration_position",
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,119 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { CertificateConfiguration } from "../../domain/certificate-configuration.aggregate";
|
||||
import { CertificateConfigurationRevision } from "../../domain/certificate-configuration-revision.entity";
|
||||
import {
|
||||
CERTIFICATE_STATE_TRANSITIONS,
|
||||
CertificateConfigurationInputSchema,
|
||||
} from "../../domain/certificate-configuration-values";
|
||||
import { CertificateConfigurationValues } from "../../domain/value-objects/certificate-configuration-values";
|
||||
|
||||
import { persistedJson, profileIdentity } from "./work-profile-persistence.mapper";
|
||||
export type CertificateRow = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
profileId: string;
|
||||
resourceId: string;
|
||||
values: unknown;
|
||||
currentKey: string | null;
|
||||
};
|
||||
export type CertificateRevisionRow = {
|
||||
id: string;
|
||||
configurationId: string;
|
||||
position: number;
|
||||
values: unknown;
|
||||
actorId: string;
|
||||
recordedAt: string;
|
||||
};
|
||||
function mapped(raw: unknown) {
|
||||
const parsed = CertificateConfigurationValues.create(
|
||||
CertificateConfigurationInputSchema.parse(persistedJson(raw))
|
||||
);
|
||||
if (!parsed.isSuccess) {
|
||||
throw new Error("Valores de certificado persistidos inconsistentes.");
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
export class CertificateConfigurationPersistenceMapper {
|
||||
toDomain(row: CertificateRow, revisions: readonly CertificateRevisionRow[]) {
|
||||
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 certificado persistido inconsistente.");
|
||||
}
|
||||
return CertificateConfigurationRevision.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 certificado persistido inconsistente.");
|
||||
}
|
||||
for (const [index, revision] of history.entries()) {
|
||||
const previous = history[index - 1]?.values;
|
||||
if (
|
||||
previous &&
|
||||
(!CERTIFICATE_STATE_TRANSITIONS[previous.state].some(
|
||||
(state) => state === revision.values.state
|
||||
) ||
|
||||
(previous.closed && !previous.equals(revision.values)))
|
||||
) {
|
||||
throw new Error("Estados de certificado históricos inconsistentes.");
|
||||
}
|
||||
}
|
||||
const keys = certificateKeys(values);
|
||||
if (
|
||||
!history.at(-1)?.values.equals(values) ||
|
||||
row.profileId !== values.profileId.toPrimitive() ||
|
||||
row.resourceId !== values.resourceId.toPrimitive() ||
|
||||
row.currentKey !== keys.currentKey
|
||||
) {
|
||||
throw new Error("Referencias de certificado persistidas inconsistentes.");
|
||||
}
|
||||
return CertificateConfiguration.rehydrate(
|
||||
{ companyId: profileIdentity(row.companyId), values, history },
|
||||
profileIdentity(row.id)
|
||||
);
|
||||
}
|
||||
toPersistence(item: CertificateConfiguration) {
|
||||
const values = item.values.toPrimitive();
|
||||
return {
|
||||
root: {
|
||||
id: item.id.toPrimitive(),
|
||||
companyId: item.companyId.toPrimitive(),
|
||||
profileId: values.profileId,
|
||||
resourceId: values.resourceId,
|
||||
values: JSON.stringify(values),
|
||||
...certificateKeys(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 certificateKeys(values: CertificateConfigurationValues) {
|
||||
return {
|
||||
currentKey: values.closed
|
||||
? null
|
||||
: `${values.profileId.toPrimitive()}:${values.resourceId.toPrimitive()}`,
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,138 @@
|
||||
import type { IResourceInventoryService } from "@metis/resources/services";
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import { QueryTypes, type Sequelize, type Transaction } from "sequelize";
|
||||
|
||||
import type {
|
||||
ApplicationContext,
|
||||
IApplicationConfigurationRepository,
|
||||
} from "../../application/application-configuration.repository";
|
||||
import { applicationReferences } from "../../application/application-configuration-policy";
|
||||
import type { IWorkProfileInventoryService } from "../../application/work-profile.repository";
|
||||
import { ApplicationConfigurationError } from "../../domain/application-configuration-values";
|
||||
|
||||
import {
|
||||
ApplicationConfigurationPersistenceMapper,
|
||||
type ApplicationRevisionRow,
|
||||
type ApplicationRow,
|
||||
} from "./application-configuration-persistence.mapper";
|
||||
import {
|
||||
type ProfileRevisionRow,
|
||||
type ProfileRow,
|
||||
WorkProfilePersistenceMapper,
|
||||
profileIdentity,
|
||||
} from "./work-profile-persistence.mapper";
|
||||
export class SequelizeApplicationConfigurationRepository
|
||||
implements IApplicationConfigurationRepository
|
||||
{
|
||||
private readonly mapper = new ApplicationConfigurationPersistenceMapper();
|
||||
constructor(
|
||||
private readonly database: Sequelize,
|
||||
private readonly inventory: IWorkProfileInventoryService,
|
||||
private readonly resources: IResourceInventoryService
|
||||
) {}
|
||||
private async load(
|
||||
company: UniqueID,
|
||||
transaction: Transaction,
|
||||
write: boolean
|
||||
): Promise<Result<ApplicationContext, ApplicationConfigurationError>> {
|
||||
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 ApplicationConfigurationError("COMPANY_NOT_FOUND"));
|
||||
}
|
||||
const rows = await this.database.query<ApplicationRow>(
|
||||
"SELECT * FROM application_configurations WHERE companyId=:companyId",
|
||||
{ ...options, type: QueryTypes.SELECT }
|
||||
);
|
||||
const revisions = await this.database.query<ApplicationRevisionRow>(
|
||||
"SELECT r.* FROM application_configuration_revisions r JOIN application_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: ApplicationContext = {
|
||||
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),
|
||||
resources: await this.resources.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) => !applicationReferences(mapping.values, context))
|
||||
) {
|
||||
throw new Error("Ámbito de aplicación persistido inconsistente.");
|
||||
}
|
||||
return Result.ok(context);
|
||||
}
|
||||
get(company: UniqueID) {
|
||||
return this.database.transaction((t) => this.load(company, t, false));
|
||||
}
|
||||
save(company: UniqueID, action: Parameters<IApplicationConfigurationRepository["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 application_configurations (id,companyId,profileId,resourceId,`values`,currentKey) VALUES (:id,:companyId,:profileId,:resourceId,:values,:currentKey) ON DUPLICATE KEY UPDATE `values`=VALUES(`values`),currentKey=VALUES(currentKey)",
|
||||
{ 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 application_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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,139 @@
|
||||
import type { IResourceInventoryService } from "@metis/resources/services";
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import { QueryTypes, type Sequelize, type Transaction } from "sequelize";
|
||||
|
||||
import type {
|
||||
CertificateContext,
|
||||
ICertificateConfigurationRepository,
|
||||
} from "../../application/certificate-configuration.repository";
|
||||
import { certificateReferences } from "../../application/certificate-configuration-policy";
|
||||
import type { IWorkProfileInventoryService } from "../../application/work-profile.repository";
|
||||
import { CertificateConfigurationError } from "../../domain/certificate-configuration-values";
|
||||
|
||||
import {
|
||||
CertificateConfigurationPersistenceMapper,
|
||||
type CertificateRevisionRow,
|
||||
type CertificateRow,
|
||||
} from "./certificate-configuration-persistence.mapper";
|
||||
import {
|
||||
type ProfileRevisionRow,
|
||||
type ProfileRow,
|
||||
WorkProfilePersistenceMapper,
|
||||
profileIdentity,
|
||||
} from "./work-profile-persistence.mapper";
|
||||
export class SequelizeCertificateConfigurationRepository
|
||||
implements ICertificateConfigurationRepository
|
||||
{
|
||||
private readonly mapper = new CertificateConfigurationPersistenceMapper();
|
||||
constructor(
|
||||
private readonly database: Sequelize,
|
||||
private readonly inventory: IWorkProfileInventoryService,
|
||||
private readonly resources: IResourceInventoryService
|
||||
) {}
|
||||
private async load(
|
||||
company: UniqueID,
|
||||
transaction: Transaction,
|
||||
write: boolean
|
||||
): Promise<Result<CertificateContext, CertificateConfigurationError>> {
|
||||
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 CertificateConfigurationError("COMPANY_NOT_FOUND"));
|
||||
}
|
||||
const rows = await this.database.query<CertificateRow>(
|
||||
"SELECT * FROM certificate_configurations WHERE companyId=:companyId",
|
||||
{ ...options, type: QueryTypes.SELECT }
|
||||
);
|
||||
const revisions = await this.database.query<CertificateRevisionRow>(
|
||||
"SELECT r.* FROM certificate_configuration_revisions r JOIN certificate_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: CertificateContext = {
|
||||
today: new Date().toISOString().slice(0, 10),
|
||||
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),
|
||||
resources: await this.resources.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) => !certificateReferences(mapping.values, context))
|
||||
) {
|
||||
throw new Error("Ámbito de certificado persistido inconsistente.");
|
||||
}
|
||||
return Result.ok(context);
|
||||
}
|
||||
get(company: UniqueID) {
|
||||
return this.database.transaction((t) => this.load(company, t, false));
|
||||
}
|
||||
save(company: UniqueID, action: Parameters<ICertificateConfigurationRepository["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 certificate_configurations (id,companyId,profileId,resourceId,`values`,currentKey) VALUES (:id,:companyId,:profileId,:resourceId,:values,:currentKey) ON DUPLICATE KEY UPDATE `values`=VALUES(`values`),currentKey=VALUES(currentKey)",
|
||||
{ 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 certificate_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,7 +1,10 @@
|
||||
import type { IResourceInventoryService } from "@metis/resources/services";
|
||||
import type { RequestHandler } from "express";
|
||||
import type { Sequelize } from "sequelize";
|
||||
|
||||
import { AccessUseCases } from "./application/access.use-cases";
|
||||
import { ApplicationConfigurationUseCases } from "./application/application-configuration.use-cases";
|
||||
import { CertificateConfigurationUseCases } from "./application/certificate-configuration.use-cases";
|
||||
import { CompanyUseCases } from "./application/company.use-cases";
|
||||
import { CompanyInputMapper } from "./application/company-input.mapper";
|
||||
import { FolderMappingUseCases } from "./application/folder-mapping.use-cases";
|
||||
@ -12,6 +15,8 @@ import { PrinterConfigurationUseCases } from "./application/printer-configuratio
|
||||
import type { IWorkProfileInventoryService } from "./application/work-profile.repository";
|
||||
import { WorkProfileUseCases } from "./application/work-profile.use-cases";
|
||||
import { registerAccessRoutes } from "./infrastructure/http/access.routes";
|
||||
import { registerApplicationConfigurationRoutes } from "./infrastructure/http/application-configuration.routes";
|
||||
import { registerCertificateConfigurationRoutes } from "./infrastructure/http/certificate-configuration.routes";
|
||||
import { registerCompanyRoutes } from "./infrastructure/http/company.routes";
|
||||
import { registerFolderMappingRoutes } from "./infrastructure/http/folder-mapping.routes";
|
||||
import { registerMailRoutes } from "./infrastructure/http/mail-configuration.routes";
|
||||
@ -23,6 +28,8 @@ import {
|
||||
SequelizeAccessRepository,
|
||||
migrateAccess,
|
||||
} from "./infrastructure/persistence/access-persistence";
|
||||
import { migrateApplicationConfigurations } from "./infrastructure/persistence/application-configuration-migration";
|
||||
import { migrateCertificateConfigurations } from "./infrastructure/persistence/certificate-configuration-migration";
|
||||
import { initializeCompanyModels } from "./infrastructure/persistence/company.model";
|
||||
import { CompanyPersistenceMapper } from "./infrastructure/persistence/company-persistence.mapper";
|
||||
import { migrateFolderMappings } from "./infrastructure/persistence/folder-mapping-migration";
|
||||
@ -32,6 +39,8 @@ 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 { SequelizeApplicationConfigurationRepository } from "./infrastructure/persistence/sequelize-application-configuration.repository";
|
||||
import { SequelizeCertificateConfigurationRepository } from "./infrastructure/persistence/sequelize-certificate-configuration.repository";
|
||||
import { SequelizeCompanyRepository } from "./infrastructure/persistence/sequelize-company.repository";
|
||||
import { SequelizeFolderMappingRepository } from "./infrastructure/persistence/sequelize-folder-mapping.repository";
|
||||
import { SequelizeMailConfigurationRepository } from "./infrastructure/persistence/sequelize-mail-configuration.repository";
|
||||
@ -56,10 +65,21 @@ export async function createCompanyModule(
|
||||
);
|
||||
const useCases = new CompanyUseCases(repository, new CompanyInputMapper());
|
||||
return {
|
||||
initializeProfiles: async (inventory: IWorkProfileInventoryService) => {
|
||||
initializeProfiles: async (
|
||||
inventory: IWorkProfileInventoryService,
|
||||
resources: IResourceInventoryService
|
||||
) => {
|
||||
await migrateWorkProfiles(database);
|
||||
await migrateFolderMappings(database);
|
||||
await migratePrinterConfigurations(database);
|
||||
await migrateApplicationConfigurations(database);
|
||||
await migrateCertificateConfigurations(database);
|
||||
const certificates = new CertificateConfigurationUseCases(
|
||||
new SequelizeCertificateConfigurationRepository(database, inventory, resources)
|
||||
);
|
||||
const applications = new ApplicationConfigurationUseCases(
|
||||
new SequelizeApplicationConfigurationRepository(database, inventory, resources)
|
||||
);
|
||||
const printers = new PrinterConfigurationUseCases(
|
||||
new SequelizePrinterConfigurationRepository(database, inventory)
|
||||
);
|
||||
@ -74,6 +94,8 @@ export async function createCompanyModule(
|
||||
registerWorkProfileRoutes(app, profiles, authenticate);
|
||||
registerFolderMappingRoutes(app, folders, authenticate);
|
||||
registerPrinterConfigurationRoutes(app, printers, authenticate);
|
||||
registerApplicationConfigurationRoutes(app, applications, authenticate);
|
||||
registerCertificateConfigurationRoutes(app, certificates, authenticate);
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
38
modules/companies/src/common/application-configurations.ts
Normal file
38
modules/companies/src/common/application-configurations.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { ApplicationConfigurationInputSchema } from "../api/domain/application-configuration-values";
|
||||
|
||||
export type { ApplicationConfigurationInput } from "../api/domain/application-configuration-values";
|
||||
export {
|
||||
APPLICATION_STATE_TRANSITIONS,
|
||||
ApplicationConfigurationInputSchema,
|
||||
} from "../api/domain/application-configuration-values";
|
||||
export const ApplicationParamsSchema = z.strictObject({ companyId: z.uuid() });
|
||||
export const ApplicationItemParamsSchema = ApplicationParamsSchema.extend({ id: z.uuid() });
|
||||
export const SaveApplicationConfigurationRequestSchema = z.strictObject({
|
||||
values: ApplicationConfigurationInputSchema,
|
||||
});
|
||||
export const ApplicationConfigurationSchema = z.strictObject({
|
||||
id: z.uuid(),
|
||||
companyId: z.uuid(),
|
||||
values: ApplicationConfigurationInputSchema,
|
||||
needsReview: z.boolean(),
|
||||
history: z
|
||||
.array(
|
||||
z.strictObject({
|
||||
id: z.uuid(),
|
||||
actorId: z.uuid(),
|
||||
actorName: z.string().min(1),
|
||||
recordedAt: z.iso.datetime(),
|
||||
values: ApplicationConfigurationInputSchema,
|
||||
})
|
||||
)
|
||||
.min(1),
|
||||
});
|
||||
export const ApplicationConfigurationsResponseSchema = z.strictObject({
|
||||
configurations: z.array(ApplicationConfigurationSchema),
|
||||
});
|
||||
export const ApplicationConfigurationResponseSchema = z.strictObject({
|
||||
configuration: ApplicationConfigurationSchema,
|
||||
});
|
||||
export type ApplicationConfigurationDTO = z.infer<typeof ApplicationConfigurationSchema>;
|
||||
40
modules/companies/src/common/certificate-configurations.ts
Normal file
40
modules/companies/src/common/certificate-configurations.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { CertificateConfigurationInputSchema } from "../api/domain/certificate-configuration-values";
|
||||
|
||||
export type { CertificateConfigurationInput } from "../api/domain/certificate-configuration-values";
|
||||
export {
|
||||
CERTIFICATE_STATE_TRANSITIONS,
|
||||
CertificateConfigurationInputSchema,
|
||||
} from "../api/domain/certificate-configuration-values";
|
||||
export const CertificateParamsSchema = z.strictObject({ companyId: z.uuid() });
|
||||
export const CertificateItemParamsSchema = CertificateParamsSchema.extend({ id: z.uuid() });
|
||||
export const SaveCertificateConfigurationRequestSchema = z.strictObject({
|
||||
values: CertificateConfigurationInputSchema,
|
||||
});
|
||||
export const CertificateConfigurationSchema = z.strictObject({
|
||||
id: z.uuid(),
|
||||
companyId: z.uuid(),
|
||||
values: CertificateConfigurationInputSchema,
|
||||
needsReview: z.boolean(),
|
||||
expiresOn: z.iso.date().nullable(),
|
||||
expired: z.boolean(),
|
||||
history: z
|
||||
.array(
|
||||
z.strictObject({
|
||||
id: z.uuid(),
|
||||
actorId: z.uuid(),
|
||||
actorName: z.string().min(1),
|
||||
recordedAt: z.iso.datetime(),
|
||||
values: CertificateConfigurationInputSchema,
|
||||
})
|
||||
)
|
||||
.min(1),
|
||||
});
|
||||
export const CertificateConfigurationsResponseSchema = z.strictObject({
|
||||
configurations: z.array(CertificateConfigurationSchema),
|
||||
});
|
||||
export const CertificateConfigurationResponseSchema = z.strictObject({
|
||||
configuration: CertificateConfigurationSchema,
|
||||
});
|
||||
export type CertificateConfigurationDTO = z.infer<typeof CertificateConfigurationSchema>;
|
||||
@ -1,3 +1,5 @@
|
||||
export * from "./application-configurations";
|
||||
export * from "./certificate-configurations";
|
||||
export * from "./dto/request/access.request.dto";
|
||||
export * from "./dto/request/company.request.dto";
|
||||
export * from "./dto/request/people.request.dto";
|
||||
|
||||
45
modules/companies/src/web/applications/application-api.ts
Normal file
45
modules/companies/src/web/applications/application-api.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
type ApplicationConfigurationInput,
|
||||
ApplicationConfigurationResponseSchema,
|
||||
ApplicationConfigurationsResponseSchema,
|
||||
} from "../../common/application-configurations";
|
||||
export const applicationKeys = { company: (id: string) => ["application-configurations", id] };
|
||||
export class ApplicationApiError extends Error {
|
||||
constructor(public readonly code: string) {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
async function request(
|
||||
companyId: string,
|
||||
values?: ApplicationConfigurationInput,
|
||||
token?: string,
|
||||
id?: string
|
||||
) {
|
||||
const response = await fetch(
|
||||
`/api/application-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 ApplicationApiError(error.success ? error.data.code : "INTERNAL_ERROR");
|
||||
}
|
||||
return body;
|
||||
}
|
||||
export const applicationApi = {
|
||||
async get(companyId: string) {
|
||||
return ApplicationConfigurationsResponseSchema.parse(await request(companyId));
|
||||
},
|
||||
async save(companyId: string, values: ApplicationConfigurationInput, token: string, id?: string) {
|
||||
return ApplicationConfigurationResponseSchema.parse(
|
||||
await request(companyId, values, token, id)
|
||||
);
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,338 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
ResourceEditor,
|
||||
catalogApi,
|
||||
catalogKeys,
|
||||
inventoryApi,
|
||||
inventoryKeys,
|
||||
} from "@metis/resources/client";
|
||||
import type { CatalogItemDTO, ResourceDTO } from "@metis/resources/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 { 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 { type UseFormReturn, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CompanyDTO, WorkProfileDTO } from "../../common";
|
||||
import {
|
||||
APPLICATION_STATE_TRANSITIONS,
|
||||
type ApplicationConfigurationDTO,
|
||||
type ApplicationConfigurationInput,
|
||||
ApplicationConfigurationInputSchema,
|
||||
} from "../../common/application-configurations";
|
||||
import { profileApi, profileKeys } from "../profiles/work-profile-api";
|
||||
import { WorkProfileForm } from "../profiles/work-profile-form";
|
||||
|
||||
import { applicationApi, applicationKeys } from "./application-api";
|
||||
import { ApplicationFeedback } from "./application-feedback";
|
||||
export function ApplicationConfigurationForm({
|
||||
company,
|
||||
csrfToken,
|
||||
configuration,
|
||||
initialProfileId = "",
|
||||
onSaved,
|
||||
}: {
|
||||
company: CompanyDTO;
|
||||
csrfToken: string;
|
||||
configuration?: ApplicationConfigurationDTO | undefined;
|
||||
initialProfileId?: string;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
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<"profile" | "application">();
|
||||
const profiles = useQuery({
|
||||
queryKey: profileKeys.company(company.id),
|
||||
queryFn: () => profileApi.get(company.id),
|
||||
});
|
||||
const resources = useQuery({
|
||||
queryKey: inventoryKeys.company(company.id),
|
||||
queryFn: () => inventoryApi.get(company.id),
|
||||
});
|
||||
const catalog = useQuery({
|
||||
queryKey: catalogKeys.scope(company.id),
|
||||
queryFn: () => catalogApi.get(company.id),
|
||||
});
|
||||
const form = useForm<ApplicationConfigurationInput>({
|
||||
resolver: zodResolver(ApplicationConfigurationInputSchema),
|
||||
defaultValues: applicationDefaults(configuration, initialProfileId),
|
||||
});
|
||||
const values = form.watch();
|
||||
const ready = [profiles.data, resources.data, catalog.data].every(Boolean);
|
||||
const fixed = Boolean(configuration || initialProfileId);
|
||||
const states = applicationStates(configuration);
|
||||
const mutation = useMutation({
|
||||
mutationFn: (input: ApplicationConfigurationInput) =>
|
||||
applicationApi.save(company.id, input, csrfToken, configuration?.id),
|
||||
onSuccess: async () => {
|
||||
await client.invalidateQueries({ queryKey: applicationKeys.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={
|
||||
fixed
|
||||
? undefined
|
||||
: { label: t("profiles.new"), onClick: () => setQuick("profile") }
|
||||
}
|
||||
disabled={fixed || !ready}
|
||||
id={`${prefix}-profile`}
|
||||
label={t("mail.profile")}
|
||||
onValueChange={(id) => form.setValue("profileId", id)}
|
||||
options={applicationProfiles(values.profileId, profiles.data?.profiles)}
|
||||
placeholder={t("mail.choose")}
|
||||
required
|
||||
value={values.profileId}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-resource`}>{t("applications.resource")}</Label>
|
||||
<SelectWithAction
|
||||
action={
|
||||
configuration
|
||||
? undefined
|
||||
: {
|
||||
label: t("applications.newResource"),
|
||||
onClick: () => setQuick("application"),
|
||||
}
|
||||
}
|
||||
disabled={!!configuration || !ready}
|
||||
id={`${prefix}-resource`}
|
||||
label={t("applications.resource")}
|
||||
onValueChange={(id) => form.setValue("resourceId", id)}
|
||||
options={applicationOptions(
|
||||
values.resourceId,
|
||||
resources.data?.resources,
|
||||
catalog.data?.items
|
||||
)}
|
||||
placeholder={t("mail.choose")}
|
||||
required
|
||||
value={values.resourceId}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-delivery`}>{t("applications.delivery")}</Label>
|
||||
<SelectWithAction
|
||||
id={`${prefix}-delivery`}
|
||||
label={t("applications.delivery")}
|
||||
onValueChange={(value) => changeDelivery(value, form)}
|
||||
options={["installed", "web"].map((value) => ({
|
||||
value,
|
||||
label: t(`applications.${value}`),
|
||||
}))}
|
||||
placeholder={t("mail.choose")}
|
||||
required
|
||||
value={values.delivery}
|
||||
/>
|
||||
</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 = ApplicationConfigurationInputSchema.shape.state.safeParse(value);
|
||||
if (parsed.success) {
|
||||
form.setValue("state", parsed.data);
|
||||
}
|
||||
}}
|
||||
options={states.map((value) => ({ value, label: t(`mail.${value}`) }))}
|
||||
placeholder={t("mail.choose")}
|
||||
required
|
||||
value={values.state}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{values.delivery === "installed" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-version`}>{t("applications.version")}</Label>
|
||||
<Input
|
||||
id={`${prefix}-version`}
|
||||
maxLength={100}
|
||||
onChange={(event) => form.setValue("version", event.target.value || null)}
|
||||
value={values.version ?? ""}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<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("applications.fixed")}</p>
|
||||
)}
|
||||
{Object.keys(form.formState.errors).length > 0 && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{t("applications.errors.INVALID_REQUEST")}
|
||||
</p>
|
||||
)}
|
||||
<ApplicationFeedback
|
||||
error={mutation.error ?? profiles.error ?? resources.error ?? catalog.error}
|
||||
/>
|
||||
<ContextHelp
|
||||
description={t("applications.description")}
|
||||
examples={t("applications.examples")}
|
||||
examplesLabel={t("mail.examplesLabel")}
|
||||
guidance={t("applications.guidance")}
|
||||
scopeNote={t("applications.scope")}
|
||||
title={t("applications.help")}
|
||||
/>
|
||||
<FormActions>
|
||||
<Button disabled={!ready || mutation.isPending} type="submit">
|
||||
{t(mutation.isPending ? "saving" : "save")}
|
||||
</Button>
|
||||
</FormActions>
|
||||
</form>
|
||||
{quick && (
|
||||
<ApplicationQuickEditor
|
||||
company={company}
|
||||
csrfToken={csrfToken}
|
||||
kind={quick}
|
||||
onClose={() => setQuick(undefined)}
|
||||
onSaved={(field, id) => {
|
||||
if (mounted.current) {
|
||||
form.setValue(field, id);
|
||||
setQuick(undefined);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function applicationOptions(
|
||||
selected: string,
|
||||
resources: readonly ResourceDTO[] = [],
|
||||
items: readonly CatalogItemDTO[] = []
|
||||
) {
|
||||
const templates = new Set(
|
||||
items
|
||||
.filter(
|
||||
(item) =>
|
||||
item.values.kind === "template" &&
|
||||
(item.builtinKey === "template:application" || item.values.usage === "application")
|
||||
)
|
||||
.map((item) => item.id)
|
||||
);
|
||||
return resources
|
||||
.filter(
|
||||
(item) =>
|
||||
templates.has(item.values.templateId) && (item.values.active || item.id === selected)
|
||||
)
|
||||
.map((item) => ({ value: item.id, label: item.values.name }));
|
||||
}
|
||||
|
||||
function applicationDefaults(
|
||||
configuration: ApplicationConfigurationDTO | undefined,
|
||||
profileId: string
|
||||
): ApplicationConfigurationInput {
|
||||
return (
|
||||
configuration?.values ?? {
|
||||
profileId,
|
||||
resourceId: "",
|
||||
delivery: "installed",
|
||||
version: null,
|
||||
state: "pending",
|
||||
notes: null,
|
||||
}
|
||||
);
|
||||
}
|
||||
function applicationStates(
|
||||
configuration: ApplicationConfigurationDTO | undefined
|
||||
): readonly ApplicationConfigurationInput["state"][] {
|
||||
return configuration
|
||||
? APPLICATION_STATE_TRANSITIONS[configuration.values.state]
|
||||
: ["pending", "configured"];
|
||||
}
|
||||
function applicationProfiles(selected: string, profiles: readonly WorkProfileDTO[] = []) {
|
||||
return profiles
|
||||
.filter((item) => item.values.active || item.id === selected)
|
||||
.map((item) => ({ value: item.id, label: item.values.name }));
|
||||
}
|
||||
function changeDelivery(value: string, form: UseFormReturn<ApplicationConfigurationInput>) {
|
||||
const parsed = ApplicationConfigurationInputSchema.shape.delivery.safeParse(value);
|
||||
if (parsed.success) {
|
||||
form.setValue("delivery", parsed.data);
|
||||
if (parsed.data === "web") {
|
||||
form.setValue("version", null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ApplicationQuickEditor({
|
||||
kind,
|
||||
company,
|
||||
csrfToken,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
kind: "profile" | "application";
|
||||
company: CompanyDTO;
|
||||
csrfToken: string;
|
||||
onClose: () => void;
|
||||
onSaved: (field: "profileId" | "resourceId", id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
return (
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
open
|
||||
title={t(kind === "profile" ? "profiles.new" : "applications.newResource")}
|
||||
wide
|
||||
>
|
||||
{kind === "profile" ? (
|
||||
<WorkProfileForm
|
||||
allowQuickCreate={false}
|
||||
company={company}
|
||||
csrfToken={csrfToken}
|
||||
onSaved={(id) => onSaved("profileId", id)}
|
||||
/>
|
||||
) : (
|
||||
<ResourceEditor
|
||||
allowQuickCreate={false}
|
||||
companyId={company.id}
|
||||
csrfToken={csrfToken}
|
||||
initialKind="application"
|
||||
onSaved={(id) => onSaved("resourceId", id)}
|
||||
/>
|
||||
)}
|
||||
</FormDialog>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,211 @@
|
||||
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
|
||||
import {
|
||||
inventoryApi as resourceApi,
|
||||
inventoryKeys as resourceKeys,
|
||||
} from "@metis/resources/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 { ApplicationConfigurationDTO, CompanyDTO } from "../../common";
|
||||
import { profileApi, profileKeys } from "../profiles/work-profile-api";
|
||||
|
||||
import { applicationApi, applicationKeys } from "./application-api";
|
||||
import { ApplicationConfigurationForm } from "./application-configuration-form";
|
||||
import { ApplicationFeedback } from "./application-feedback";
|
||||
export function ApplicationConfigurationsPage({
|
||||
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: applicationKeys.company(company.id),
|
||||
queryFn: () => applicationApi.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 resources = useQuery({
|
||||
queryKey: resourceKeys.company(company.id),
|
||||
queryFn: () => resourceApi.get(company.id),
|
||||
});
|
||||
const resource = (id: string) =>
|
||||
resources.data?.resources.find((item) => item.id === id)?.values.name ?? 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) &&
|
||||
`${resource(item.values.resourceId)} ${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, resources.data].every(Boolean);
|
||||
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("applications.title")}</h2>
|
||||
<Button disabled={!(ready && canCreate)} onClick={() => setEditor("new")}>
|
||||
{t("applications.new")}
|
||||
</Button>
|
||||
</header>
|
||||
<Input
|
||||
aria-label={t("applications.search")}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t("applications.search")}
|
||||
value={search}
|
||||
/>
|
||||
{(query.isPending || profiles.isPending || inventory.isPending || resources.isPending) && (
|
||||
<p role="status">{t("loading")}</p>
|
||||
)}
|
||||
<ApplicationFeedback
|
||||
error={query.error ?? profiles.error ?? inventory.error ?? resources.error}
|
||||
/>
|
||||
{(query.isError || profiles.isError || inventory.isError || resources.isError) && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
Promise.all([
|
||||
query.refetch(),
|
||||
profiles.refetch(),
|
||||
inventory.refetch(),
|
||||
resources.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("applications.empty")}</p>
|
||||
)}
|
||||
{filtered.map((item) => (
|
||||
<div className="flex flex-wrap justify-between gap-4 py-4" key={item.id}>
|
||||
<div>
|
||||
<h3 className="font-medium">{resource(item.values.resourceId)}</h3>
|
||||
<p className="text-sm text-muted-foreground">{label(item.values.profileId)}</p>
|
||||
<p className="text-sm">
|
||||
{t(`applications.${item.values.delivery}`)} · {t(`mail.${item.values.state}`)}
|
||||
</p>
|
||||
{item.values.version && (
|
||||
<p className="text-sm">
|
||||
{t("applications.versionValue", { version: item.values.version })}
|
||||
</p>
|
||||
)}
|
||||
{item.needsReview && (
|
||||
<p className="text-sm text-amber-700">{t("applications.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 ? "applications.edit" : "applications.new")}
|
||||
wide
|
||||
>
|
||||
<ApplicationConfigurationForm
|
||||
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
|
||||
>
|
||||
<ApplicationHistory configuration={viewed} />
|
||||
</FormDialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function ApplicationHistory({ configuration }: { configuration: ApplicationConfigurationDTO }) {
|
||||
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(`applications.${revision.values.delivery}`)} · {t(`mail.${revision.values.state}`)}
|
||||
</p>
|
||||
{revision.values.version && (
|
||||
<p>{t("applications.versionValue", { version: revision.values.version })}</p>
|
||||
)}
|
||||
{revision.values.notes && (
|
||||
<p className="mt-2 whitespace-pre-wrap">{revision.values.notes}</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ApplicationApiError } from "./application-api";
|
||||
export function ApplicationFeedback({ error }: { error: Error | null }) {
|
||||
const { t } = useTranslation("companies");
|
||||
return error ? (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{t(
|
||||
`applications.errors.${error instanceof ApplicationApiError ? error.code : "INTERNAL_ERROR"}`
|
||||
)}
|
||||
</p>
|
||||
) : null;
|
||||
}
|
||||
83
modules/companies/src/web/applications/application-i18n.ts
Normal file
83
modules/companies/src/web/applications/application-i18n.ts
Normal file
@ -0,0 +1,83 @@
|
||||
export const applicationsSpanish = {
|
||||
title: "Aplicaciones del perfil",
|
||||
new: "Configurar aplicación",
|
||||
edit: "Editar configuración de aplicación",
|
||||
resource: "Aplicación",
|
||||
newResource: "Nueva aplicación",
|
||||
delivery: "Forma de uso",
|
||||
installed: "Instalada en el equipo",
|
||||
web: "Acceso web",
|
||||
version: "Versión (opcional)",
|
||||
versionValue: "Versión: {{version}}",
|
||||
search: "Buscar aplicación, perfil, persona o equipo…",
|
||||
empty: "Todavía no hay aplicaciones registradas en estos perfiles.",
|
||||
review:
|
||||
"Revisar: hay una retirada pendiente o el recurso, perfil, persona o equipo ya no está disponible.",
|
||||
fixed:
|
||||
"El perfil y la aplicación quedan fijos. Para otro destino, cierra esta configuración y crea una nueva.",
|
||||
help: "¿Para qué sirve esta configuración?",
|
||||
description:
|
||||
"Registra qué aplicaciones necesita una persona en el perfil de su equipo y si están listas para usar.",
|
||||
scope:
|
||||
"La aplicación es un recurso de esta compañía. Su proveedor y URL se gestionan en Recursos; la versión y el estado corresponden a este perfil.",
|
||||
examples:
|
||||
"Thunderbird instalado por petición de Ana; Zoom corporativo instalado; ERP utilizado desde el navegador.",
|
||||
guidance:
|
||||
"Pendiente de configurar indica una necesidad; Configurado indica disponibilidad comprobada. No instala programas ni concede permisos. No exige una licencia o cuenta informática. Las altas rápidas guardadas se conservan aunque canceles este formulario.",
|
||||
errors: {
|
||||
INVALID_REQUEST: "Revisa el formulario. La versión solo corresponde a aplicaciones instaladas.",
|
||||
APPLICATION_REFERENCE_NOT_FOUND:
|
||||
"Selecciona un perfil y un recurso de una plantilla de aplicación de esta compañía.",
|
||||
APPLICATION_REFERENCE_UNAVAILABLE:
|
||||
"El perfil, la persona, el equipo o la aplicación no están disponibles para configurar.",
|
||||
APPLICATION_TARGET_IMMUTABLE: "El perfil y la aplicación no se pueden cambiar.",
|
||||
APPLICATION_ALREADY_OPEN: "Esta aplicación ya tiene una configuración abierta en el perfil.",
|
||||
INVALID_APPLICATION_TRANSITION: "El cambio de estado no está permitido.",
|
||||
APPLICATION_CONFIGURATION_NOT_FOUND: "No se ha encontrado la configuración.",
|
||||
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 applicationsEnglish = {
|
||||
title: "Profile applications",
|
||||
new: "Configure application",
|
||||
edit: "Edit application configuration",
|
||||
resource: "Application",
|
||||
newResource: "New application",
|
||||
delivery: "Usage",
|
||||
installed: "Installed on device",
|
||||
web: "Web access",
|
||||
version: "Version (optional)",
|
||||
versionValue: "Version: {{version}}",
|
||||
search: "Search application, profile, person or device…",
|
||||
empty: "No applications have been registered for these profiles yet.",
|
||||
review: "Review: removal is pending or the resource, profile, person or device is unavailable.",
|
||||
fixed:
|
||||
"The profile and application are fixed. For another target, close this configuration and create a new one.",
|
||||
help: "What is this configuration for?",
|
||||
description:
|
||||
"Record the applications a person needs in their device profile and whether they are ready to use.",
|
||||
scope:
|
||||
"The application is a company resource. Manage its provider and URL in Resources; version and state belong to this profile.",
|
||||
examples:
|
||||
"Thunderbird installed at Ana's request; installed corporate Zoom; ERP accessed in a browser.",
|
||||
guidance:
|
||||
"Pending means a need; Configured means verified availability. This does not install software or grant permissions. No license or account is required. Saved quick additions remain if you cancel this form.",
|
||||
errors: {
|
||||
INVALID_REQUEST: "Check the form. Version applies only to installed applications.",
|
||||
APPLICATION_REFERENCE_NOT_FOUND:
|
||||
"Select a company profile and a resource from an application template.",
|
||||
APPLICATION_REFERENCE_UNAVAILABLE:
|
||||
"The profile, person, device or application is unavailable for configuration.",
|
||||
APPLICATION_TARGET_IMMUTABLE: "The profile and application cannot change.",
|
||||
APPLICATION_ALREADY_OPEN: "This application already has an open configuration in the profile.",
|
||||
INVALID_APPLICATION_TRANSITION: "This state change is not allowed.",
|
||||
APPLICATION_CONFIGURATION_NOT_FOUND: "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.",
|
||||
},
|
||||
};
|
||||
86
modules/companies/src/web/applications/application.test.tsx
Normal file
86
modules/companies/src/web/applications/application.test.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { inventoryKeys } from "@metis/assets/client";
|
||||
import { catalogKeys, inventoryKeys as resourceKeys } from "@metis/resources/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 { applicationKeys } from "./application-api";
|
||||
import { ApplicationConfigurationForm } from "./application-configuration-form";
|
||||
import { ApplicationConfigurationsPage } from "./application-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(resourceKeys.company(company.id), { resources: [] });
|
||||
client.setQueryData(catalogKeys.scope(company.id), { items: [] });
|
||||
client.setQueryData(applicationKeys.company(company.id), { configurations: [] });
|
||||
try {
|
||||
return renderToStaticMarkup(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<QueryClientProvider client={client}>{node}</QueryClientProvider>
|
||||
</I18nextProvider>
|
||||
);
|
||||
} finally {
|
||||
client.clear();
|
||||
}
|
||||
}
|
||||
test("aplicaciones: listado sin formularios, alta en cabecera y traducciones", async () => {
|
||||
for (const [language, title, action] of [
|
||||
["es", "Aplicaciones del perfil", "Configurar aplicación"],
|
||||
["en", "Profile applications", "Configure application"],
|
||||
]) {
|
||||
const html = await render(
|
||||
language ?? "es",
|
||||
<ApplicationConfigurationsPage company={company} csrfToken="test" />
|
||||
);
|
||||
assert.ok(html.includes(title ?? ""));
|
||||
assert.ok(html.includes(action ?? ""));
|
||||
assert.ok(!html.includes("<form"));
|
||||
assert.ok(!html.includes("applications."));
|
||||
}
|
||||
});
|
||||
test("aplicaciones: selectores con alta rápida y ayuda inferior antes de guardar", async () => {
|
||||
for (const language of ["es", "en"]) {
|
||||
const html = await render(
|
||||
language,
|
||||
<ApplicationConfigurationForm company={company} csrfToken="test" onSaved={() => undefined} />
|
||||
);
|
||||
const text = language === "es" ? companiesSpanish : companiesEnglish;
|
||||
assert.ok(html.includes(text.applications.resource));
|
||||
assert.ok(html.includes(text.applications.version));
|
||||
assert.ok(html.includes(text.applications.newResource));
|
||||
assert.ok(html.indexOf(text.applications.help) > html.indexOf(text.applications.delivery));
|
||||
assert.ok(html.indexOf(text.save) > html.indexOf(text.applications.help));
|
||||
assert.ok(!html.includes("applications."));
|
||||
}
|
||||
});
|
||||
45
modules/companies/src/web/certificates/certificate-api.ts
Normal file
45
modules/companies/src/web/certificates/certificate-api.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
type CertificateConfigurationInput,
|
||||
CertificateConfigurationResponseSchema,
|
||||
CertificateConfigurationsResponseSchema,
|
||||
} from "../../common/certificate-configurations";
|
||||
export const certificateKeys = { company: (id: string) => ["certificate-configurations", id] };
|
||||
export class CertificateApiError extends Error {
|
||||
constructor(public readonly code: string) {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
async function request(
|
||||
companyId: string,
|
||||
values?: CertificateConfigurationInput,
|
||||
token?: string,
|
||||
id?: string
|
||||
) {
|
||||
const response = await fetch(
|
||||
`/api/certificate-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 CertificateApiError(error.success ? error.data.code : "INTERNAL_ERROR");
|
||||
}
|
||||
return body;
|
||||
}
|
||||
export const certificateApi = {
|
||||
async get(companyId: string) {
|
||||
return CertificateConfigurationsResponseSchema.parse(await request(companyId));
|
||||
},
|
||||
async save(companyId: string, values: CertificateConfigurationInput, token: string, id?: string) {
|
||||
return CertificateConfigurationResponseSchema.parse(
|
||||
await request(companyId, values, token, id)
|
||||
);
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,310 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
ResourceEditor,
|
||||
catalogApi,
|
||||
catalogKeys,
|
||||
inventoryApi,
|
||||
inventoryKeys,
|
||||
} from "@metis/resources/client";
|
||||
import type { CatalogItemDTO, ResourceDTO } from "@metis/resources/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 { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CompanyDTO, WorkProfileDTO } from "../../common";
|
||||
import {
|
||||
CERTIFICATE_STATE_TRANSITIONS,
|
||||
type CertificateConfigurationDTO,
|
||||
type CertificateConfigurationInput,
|
||||
CertificateConfigurationInputSchema,
|
||||
} from "../../common/certificate-configurations";
|
||||
import { profileApi, profileKeys } from "../profiles/work-profile-api";
|
||||
import { WorkProfileForm } from "../profiles/work-profile-form";
|
||||
|
||||
import { certificateApi, certificateKeys } from "./certificate-api";
|
||||
import { CertificateFeedback } from "./certificate-feedback";
|
||||
export function CertificateConfigurationForm({
|
||||
company,
|
||||
csrfToken,
|
||||
configuration,
|
||||
initialProfileId = "",
|
||||
onSaved,
|
||||
}: {
|
||||
company: CompanyDTO;
|
||||
csrfToken: string;
|
||||
configuration?: CertificateConfigurationDTO | undefined;
|
||||
initialProfileId?: string;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
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<"profile" | "certificate">();
|
||||
const profiles = useQuery({
|
||||
queryKey: profileKeys.company(company.id),
|
||||
queryFn: () => profileApi.get(company.id),
|
||||
});
|
||||
const resources = useQuery({
|
||||
queryKey: inventoryKeys.company(company.id),
|
||||
queryFn: () => inventoryApi.get(company.id),
|
||||
});
|
||||
const catalog = useQuery({
|
||||
queryKey: catalogKeys.scope(company.id),
|
||||
queryFn: () => catalogApi.get(company.id),
|
||||
});
|
||||
const form = useForm<CertificateConfigurationInput>({
|
||||
resolver: zodResolver(CertificateConfigurationInputSchema),
|
||||
defaultValues: certificateDefaults(configuration, initialProfileId),
|
||||
});
|
||||
const values = form.watch();
|
||||
const ready = [profiles.data, resources.data, catalog.data].every(Boolean);
|
||||
const fixed = Boolean(configuration || initialProfileId);
|
||||
const states = certificateStates(configuration);
|
||||
const mutation = useMutation({
|
||||
mutationFn: (input: CertificateConfigurationInput) =>
|
||||
certificateApi.save(company.id, input, csrfToken, configuration?.id),
|
||||
onSuccess: async () => {
|
||||
await client.invalidateQueries({ queryKey: certificateKeys.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={
|
||||
fixed
|
||||
? undefined
|
||||
: { label: t("profiles.new"), onClick: () => setQuick("profile") }
|
||||
}
|
||||
disabled={fixed || !ready}
|
||||
id={`${prefix}-profile`}
|
||||
label={t("mail.profile")}
|
||||
onValueChange={(id) => form.setValue("profileId", id)}
|
||||
options={certificateProfiles(values.profileId, profiles.data?.profiles)}
|
||||
placeholder={t("mail.choose")}
|
||||
required
|
||||
value={values.profileId}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-resource`}>{t("certificates.resource")}</Label>
|
||||
<SelectWithAction
|
||||
action={
|
||||
configuration
|
||||
? undefined
|
||||
: {
|
||||
label: t("certificates.newResource"),
|
||||
onClick: () => setQuick("certificate"),
|
||||
}
|
||||
}
|
||||
disabled={!!configuration || !ready}
|
||||
id={`${prefix}-resource`}
|
||||
label={t("certificates.resource")}
|
||||
onValueChange={(id) => form.setValue("resourceId", id)}
|
||||
options={certificateOptions(
|
||||
values.resourceId,
|
||||
resources.data?.resources,
|
||||
catalog.data?.items
|
||||
)}
|
||||
placeholder={t("mail.choose")}
|
||||
required
|
||||
value={values.resourceId}
|
||||
/>
|
||||
</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 = CertificateConfigurationInputSchema.shape.state.safeParse(value);
|
||||
if (parsed.success) {
|
||||
form.setValue("state", parsed.data);
|
||||
}
|
||||
}}
|
||||
options={states.map((value) => ({ value, label: t(`mail.${value}`) }))}
|
||||
placeholder={t("mail.choose")}
|
||||
required
|
||||
value={values.state}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-location`}>{t("certificates.location")}</Label>
|
||||
<Input
|
||||
id={`${prefix}-location`}
|
||||
maxLength={255}
|
||||
onChange={(event) => form.setValue("location", event.target.value || null)}
|
||||
value={values.location ?? ""}
|
||||
/>
|
||||
</div>
|
||||
<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("certificates.fixed")}</p>
|
||||
)}
|
||||
{Object.keys(form.formState.errors).length > 0 && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{t("certificates.errors.INVALID_REQUEST")}
|
||||
</p>
|
||||
)}
|
||||
<CertificateFeedback
|
||||
error={mutation.error ?? profiles.error ?? resources.error ?? catalog.error}
|
||||
/>
|
||||
<ContextHelp
|
||||
description={t("certificates.description")}
|
||||
examples={t("certificates.examples")}
|
||||
examplesLabel={t("mail.examplesLabel")}
|
||||
guidance={t("certificates.guidance")}
|
||||
scopeNote={t("certificates.scope")}
|
||||
title={t("certificates.help")}
|
||||
/>
|
||||
<FormActions>
|
||||
<Button disabled={!ready || mutation.isPending} type="submit">
|
||||
{t(mutation.isPending ? "saving" : "save")}
|
||||
</Button>
|
||||
</FormActions>
|
||||
</form>
|
||||
{quick && (
|
||||
<CertificateQuickEditor
|
||||
company={company}
|
||||
csrfToken={csrfToken}
|
||||
kind={quick}
|
||||
onClose={() => setQuick(undefined)}
|
||||
onSaved={(field, id) => {
|
||||
if (mounted.current) {
|
||||
form.setValue(field, id);
|
||||
setQuick(undefined);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function certificateOptions(
|
||||
selected: string,
|
||||
resources: readonly ResourceDTO[] = [],
|
||||
items: readonly CatalogItemDTO[] = []
|
||||
) {
|
||||
const templates = new Set(
|
||||
items
|
||||
.filter(
|
||||
(item) =>
|
||||
item.values.kind === "template" &&
|
||||
(item.builtinKey === "template:certificate" || item.values.usage === "certificate")
|
||||
)
|
||||
.map((item) => item.id)
|
||||
);
|
||||
return resources
|
||||
.filter(
|
||||
(item) =>
|
||||
templates.has(item.values.templateId) && (item.values.active || item.id === selected)
|
||||
)
|
||||
.map((item) => ({ value: item.id, label: item.values.name }));
|
||||
}
|
||||
|
||||
function certificateDefaults(
|
||||
configuration: CertificateConfigurationDTO | undefined,
|
||||
profileId: string
|
||||
): CertificateConfigurationInput {
|
||||
return (
|
||||
configuration?.values ?? {
|
||||
profileId,
|
||||
resourceId: "",
|
||||
location: null,
|
||||
state: "pending",
|
||||
notes: null,
|
||||
}
|
||||
);
|
||||
}
|
||||
function certificateStates(
|
||||
configuration: CertificateConfigurationDTO | undefined
|
||||
): readonly CertificateConfigurationInput["state"][] {
|
||||
return configuration
|
||||
? CERTIFICATE_STATE_TRANSITIONS[configuration.values.state]
|
||||
: ["pending", "configured"];
|
||||
}
|
||||
function certificateProfiles(selected: string, profiles: readonly WorkProfileDTO[] = []) {
|
||||
return profiles
|
||||
.filter((item) => item.values.active || item.id === selected)
|
||||
.map((item) => ({ value: item.id, label: item.values.name }));
|
||||
}
|
||||
function CertificateQuickEditor({
|
||||
kind,
|
||||
company,
|
||||
csrfToken,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
kind: "profile" | "certificate";
|
||||
company: CompanyDTO;
|
||||
csrfToken: string;
|
||||
onClose: () => void;
|
||||
onSaved: (field: "profileId" | "resourceId", id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
return (
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
open
|
||||
title={t(kind === "profile" ? "profiles.new" : "certificates.newResource")}
|
||||
wide
|
||||
>
|
||||
{kind === "profile" ? (
|
||||
<WorkProfileForm
|
||||
allowQuickCreate={false}
|
||||
company={company}
|
||||
csrfToken={csrfToken}
|
||||
onSaved={(id) => onSaved("profileId", id)}
|
||||
/>
|
||||
) : (
|
||||
<ResourceEditor
|
||||
allowQuickCreate={false}
|
||||
companyId={company.id}
|
||||
csrfToken={csrfToken}
|
||||
initialKind="certificate"
|
||||
onSaved={(id) => onSaved("resourceId", id)}
|
||||
/>
|
||||
)}
|
||||
</FormDialog>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,218 @@
|
||||
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
|
||||
import {
|
||||
inventoryApi as resourceApi,
|
||||
inventoryKeys as resourceKeys,
|
||||
} from "@metis/resources/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 { CertificateConfigurationDTO, CompanyDTO } from "../../common";
|
||||
import { profileApi, profileKeys } from "../profiles/work-profile-api";
|
||||
|
||||
import { certificateApi, certificateKeys } from "./certificate-api";
|
||||
import { CertificateConfigurationForm } from "./certificate-configuration-form";
|
||||
import { CertificateFeedback } from "./certificate-feedback";
|
||||
export function CertificateConfigurationsPage({
|
||||
company,
|
||||
csrfToken,
|
||||
initialProfileId,
|
||||
}: {
|
||||
company: CompanyDTO;
|
||||
csrfToken: string;
|
||||
initialProfileId?: string;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("companies");
|
||||
const [editor, setEditor] = useState<string>();
|
||||
const [view, setView] = useState<string>();
|
||||
const [search, setSearch] = useState("");
|
||||
const query = useQuery({
|
||||
queryKey: certificateKeys.company(company.id),
|
||||
queryFn: () => certificateApi.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 resources = useQuery({
|
||||
queryKey: resourceKeys.company(company.id),
|
||||
queryFn: () => resourceApi.get(company.id),
|
||||
});
|
||||
const resource = (id: string) =>
|
||||
resources.data?.resources.find((item) => item.id === id)?.values.name ?? 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) &&
|
||||
`${resource(item.values.resourceId)} ${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, resources.data].every(Boolean);
|
||||
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("certificates.title")}</h2>
|
||||
<Button disabled={!(ready && canCreate)} onClick={() => setEditor("new")}>
|
||||
{t("certificates.new")}
|
||||
</Button>
|
||||
</header>
|
||||
<Input
|
||||
aria-label={t("certificates.search")}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t("certificates.search")}
|
||||
value={search}
|
||||
/>
|
||||
{(query.isPending || profiles.isPending || inventory.isPending || resources.isPending) && (
|
||||
<p role="status">{t("loading")}</p>
|
||||
)}
|
||||
<CertificateFeedback
|
||||
error={query.error ?? profiles.error ?? inventory.error ?? resources.error}
|
||||
/>
|
||||
{(query.isError || profiles.isError || inventory.isError || resources.isError) && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
Promise.all([
|
||||
query.refetch(),
|
||||
profiles.refetch(),
|
||||
inventory.refetch(),
|
||||
resources.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("certificates.empty")}</p>
|
||||
)}
|
||||
{filtered.map((item) => (
|
||||
<div className="flex flex-wrap justify-between gap-4 py-4" key={item.id}>
|
||||
<div>
|
||||
<h3 className="font-medium">{resource(item.values.resourceId)}</h3>
|
||||
<p className="text-sm text-muted-foreground">{label(item.values.profileId)}</p>
|
||||
<p className="text-sm">{t(`mail.${item.values.state}`)}</p>
|
||||
{item.values.location && (
|
||||
<p className="text-sm">
|
||||
{t("certificates.locationValue", { location: item.values.location })}
|
||||
</p>
|
||||
)}
|
||||
{item.expiresOn && (
|
||||
<p className="text-sm">
|
||||
{t("certificates.expiryValue", {
|
||||
date: new Intl.DateTimeFormat(i18n.language, {
|
||||
dateStyle: "medium",
|
||||
timeZone: "UTC",
|
||||
}).format(new Date(item.expiresOn)),
|
||||
})}
|
||||
{item.expired ? ` · ${t("certificates.expired")}` : ""}
|
||||
</p>
|
||||
)}
|
||||
{item.needsReview && (
|
||||
<p className="text-sm text-amber-700">{t("certificates.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 ? "certificates.edit" : "certificates.new")}
|
||||
wide
|
||||
>
|
||||
<CertificateConfigurationForm
|
||||
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
|
||||
>
|
||||
<CertificateHistory configuration={viewed} />
|
||||
</FormDialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function CertificateHistory({ configuration }: { configuration: CertificateConfigurationDTO }) {
|
||||
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(`mail.${revision.values.state}`)}</p>
|
||||
{revision.values.location && (
|
||||
<p>{t("certificates.locationValue", { location: revision.values.location })}</p>
|
||||
)}
|
||||
{revision.values.notes && (
|
||||
<p className="mt-2 whitespace-pre-wrap">{revision.values.notes}</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { CertificateApiError } from "./certificate-api";
|
||||
export function CertificateFeedback({ error }: { error: Error | null }) {
|
||||
const { t } = useTranslation("companies");
|
||||
return error ? (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{t(
|
||||
`certificates.errors.${error instanceof CertificateApiError ? error.code : "INTERNAL_ERROR"}`
|
||||
)}
|
||||
</p>
|
||||
) : null;
|
||||
}
|
||||
82
modules/companies/src/web/certificates/certificate-i18n.ts
Normal file
82
modules/companies/src/web/certificates/certificate-i18n.ts
Normal file
@ -0,0 +1,82 @@
|
||||
export const certificatesSpanish = {
|
||||
title: "Certificados del perfil",
|
||||
new: "Configurar certificado",
|
||||
edit: "Editar configuración de certificado",
|
||||
resource: "Certificado digital",
|
||||
newResource: "Nuevo certificado digital",
|
||||
location: "Almacén o ubicación (opcional)",
|
||||
expiryValue: "Caducidad: {{date}}",
|
||||
expired: "Caducado",
|
||||
locationValue: "Ubicación: {{location}}",
|
||||
search: "Buscar certificado, perfil, persona o equipo…",
|
||||
empty: "Todavía no hay certificados registrados en estos perfiles.",
|
||||
review:
|
||||
"Revisar: hay una retirada pendiente, el certificado ha caducado o el recurso, perfil, persona o equipo ya no está disponible.",
|
||||
fixed:
|
||||
"El perfil y el certificado quedan fijos. Para otro destino, cierra esta configuración y crea una nueva.",
|
||||
help: "¿Para qué sirve esta configuración?",
|
||||
description:
|
||||
"Registra qué certificados necesita una persona en el perfil de su equipo y si están listos para usar.",
|
||||
scope:
|
||||
"El certificado es un recurso de la compañía. Titular, emisor y caducidad se gestionan en Recursos; la ubicación y el estado corresponden a este perfil.",
|
||||
examples:
|
||||
"Almacén de Windows del usuario, almacén de Firefox o tarjeta criptográfica. Un certificado puede utilizarse en varios perfiles.",
|
||||
guidance:
|
||||
"Pendiente de configurar indica una necesidad; Configurado indica disponibilidad comprobada. La ubicación puede dejarse vacía. Este registro no instala, autoriza ni retira certificados reales. No adjuntes certificados, claves privadas, PIN o contraseñas. Las altas rápidas guardadas se conservan aunque canceles este formulario.",
|
||||
errors: {
|
||||
INVALID_REQUEST: "Revisa los datos del formulario.",
|
||||
CERTIFICATE_REFERENCE_NOT_FOUND:
|
||||
"Selecciona un perfil y un recurso de una plantilla de certificado digital de esta compañía.",
|
||||
CERTIFICATE_REFERENCE_UNAVAILABLE:
|
||||
"El certificado ha caducado o el perfil, persona, equipo o recurso no están disponibles para configurar.",
|
||||
CERTIFICATE_TARGET_IMMUTABLE: "El perfil y el certificado no se pueden cambiar.",
|
||||
CERTIFICATE_ALREADY_OPEN: "Este certificado ya tiene una configuración abierta en el perfil.",
|
||||
INVALID_CERTIFICATE_TRANSITION: "El cambio de estado no está permitido.",
|
||||
CERTIFICATE_CONFIGURATION_NOT_FOUND: "No se ha encontrado la configuración.",
|
||||
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 certificatesEnglish = {
|
||||
title: "Profile certificates",
|
||||
new: "Configure certificate",
|
||||
edit: "Edit certificate configuration",
|
||||
resource: "Digital certificate",
|
||||
newResource: "New digital certificate",
|
||||
location: "Store or location (optional)",
|
||||
expiryValue: "Expires: {{date}}",
|
||||
expired: "Expired",
|
||||
locationValue: "Location: {{location}}",
|
||||
search: "Search certificate, profile, person or device…",
|
||||
empty: "No certificates have been registered for these profiles yet.",
|
||||
review:
|
||||
"Review: removal is pending, the certificate has expired, or the resource, profile, person or device is unavailable.",
|
||||
fixed:
|
||||
"The profile and certificate are fixed. For another target, close this configuration and create a new one.",
|
||||
help: "What is this configuration for?",
|
||||
description:
|
||||
"Record the certificates a person needs in their device profile and whether they are ready to use.",
|
||||
scope:
|
||||
"The certificate is a company resource. Manage holder, issuer and expiry in Resources; location and state belong to this profile.",
|
||||
examples:
|
||||
"Windows user store, Firefox store or smart card. A certificate may be used in several profiles.",
|
||||
guidance:
|
||||
"Pending means a need; Configured means verified availability. Location may be left empty. This record does not install, authorize or remove real certificates. Do not attach certificates, private keys, PINs or passwords. Saved quick additions remain if you cancel this form.",
|
||||
errors: {
|
||||
INVALID_REQUEST: "Check the form data.",
|
||||
CERTIFICATE_REFERENCE_NOT_FOUND:
|
||||
"Select a company profile and a resource from a digital certificate template.",
|
||||
CERTIFICATE_REFERENCE_UNAVAILABLE:
|
||||
"The certificate has expired or the profile, person, device or resource is unavailable for configuration.",
|
||||
CERTIFICATE_TARGET_IMMUTABLE: "The profile and certificate cannot change.",
|
||||
CERTIFICATE_ALREADY_OPEN: "This certificate already has an open configuration in the profile.",
|
||||
INVALID_CERTIFICATE_TRANSITION: "This state change is not allowed.",
|
||||
CERTIFICATE_CONFIGURATION_NOT_FOUND: "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.",
|
||||
},
|
||||
};
|
||||
86
modules/companies/src/web/certificates/certificate.test.tsx
Normal file
86
modules/companies/src/web/certificates/certificate.test.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { inventoryKeys } from "@metis/assets/client";
|
||||
import { catalogKeys, inventoryKeys as resourceKeys } from "@metis/resources/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 { certificateKeys } from "./certificate-api";
|
||||
import { CertificateConfigurationForm } from "./certificate-configuration-form";
|
||||
import { CertificateConfigurationsPage } from "./certificate-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(resourceKeys.company(company.id), { resources: [] });
|
||||
client.setQueryData(catalogKeys.scope(company.id), { items: [] });
|
||||
client.setQueryData(certificateKeys.company(company.id), { configurations: [] });
|
||||
try {
|
||||
return renderToStaticMarkup(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<QueryClientProvider client={client}>{node}</QueryClientProvider>
|
||||
</I18nextProvider>
|
||||
);
|
||||
} finally {
|
||||
client.clear();
|
||||
}
|
||||
}
|
||||
test("certificados: listado sin formularios, alta en cabecera y traducciones", async () => {
|
||||
for (const [language, title, action] of [
|
||||
["es", "Certificados del perfil", "Configurar certificado"],
|
||||
["en", "Profile certificates", "Configure certificate"],
|
||||
]) {
|
||||
const html = await render(
|
||||
language ?? "es",
|
||||
<CertificateConfigurationsPage company={company} csrfToken="test" />
|
||||
);
|
||||
assert.ok(html.includes(title ?? ""));
|
||||
assert.ok(html.includes(action ?? ""));
|
||||
assert.ok(!html.includes("<form"));
|
||||
assert.ok(!html.includes("certificates.help"));
|
||||
}
|
||||
});
|
||||
test("certificados: selectores con alta rápida y ayuda inferior antes de guardar", async () => {
|
||||
for (const language of ["es", "en"]) {
|
||||
const html = await render(
|
||||
language,
|
||||
<CertificateConfigurationForm company={company} csrfToken="test" onSaved={() => undefined} />
|
||||
);
|
||||
const text = language === "es" ? companiesSpanish : companiesEnglish;
|
||||
assert.ok(html.includes(text.certificates.resource));
|
||||
assert.ok(html.includes(text.certificates.location));
|
||||
assert.ok(html.includes(text.certificates.newResource));
|
||||
assert.ok(html.indexOf(text.certificates.help) > html.indexOf(text.certificates.location));
|
||||
assert.ok(html.indexOf(text.save) > html.indexOf(text.certificates.help));
|
||||
assert.ok(!html.includes("certificates.help"));
|
||||
}
|
||||
});
|
||||
@ -21,6 +21,8 @@ import { useTranslation } from "react-i18next";
|
||||
import type { CompanyDTO } from "../../common";
|
||||
import { AccessWorkspacePage } from "../access/access-workspace";
|
||||
import { companyApi, companyKeys } from "../api/company-api";
|
||||
import { ApplicationConfigurationsPage } from "../applications/application-configurations-page";
|
||||
import { CertificateConfigurationsPage } from "../certificates/certificate-configurations-page";
|
||||
import { FolderMappingsPage } from "../folders/folder-mappings-page";
|
||||
import { MailConfigurationsPage } from "../mail/mail-configurations-page";
|
||||
import { MailboxRelations } from "../mail/mailbox-relations";
|
||||
@ -117,6 +119,8 @@ type CompanyTab =
|
||||
| "assets"
|
||||
| "inventory"
|
||||
| "workstations"
|
||||
| "certificates"
|
||||
| "applications"
|
||||
| "printers"
|
||||
| "folders"
|
||||
| "mail"
|
||||
@ -221,6 +225,8 @@ export function CompanyDetail({
|
||||
["sites", `${t("sites")} (${company.sites.length})`, MapPinIcon],
|
||||
["access", accessTranslation("title"), KeyRoundIcon],
|
||||
["folders", t("folders.title"), MailIcon],
|
||||
["certificates", t("certificates.title"), LayersIcon],
|
||||
["applications", t("applications.title"), LayersIcon],
|
||||
["printers", t("printers.title"), LayersIcon],
|
||||
["mail", t("mail.title"), MailIcon],
|
||||
["assets", assetTranslation("companyTitle"), LayoutTemplateIcon],
|
||||
@ -271,6 +277,10 @@ function CompanySection({
|
||||
);
|
||||
case "profiles":
|
||||
return <WorkProfilesPage company={company} csrfToken={csrfToken} />;
|
||||
case "certificates":
|
||||
return <CertificateConfigurationsPage company={company} csrfToken={csrfToken} />;
|
||||
case "applications":
|
||||
return <ApplicationConfigurationsPage company={company} csrfToken={csrfToken} />;
|
||||
case "printers":
|
||||
return <PrinterConfigurationsPage company={company} csrfToken={csrfToken} />;
|
||||
case "folders":
|
||||
|
||||
@ -1,10 +1,16 @@
|
||||
import { applicationsEnglish, applicationsSpanish } from "./applications/application-i18n";
|
||||
import { certificatesEnglish, certificatesSpanish } from "./certificates/certificate-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 { environmentEnglish, environmentSpanish } from "./profiles/environment-i18n";
|
||||
import { profilesEnglish, profilesSpanish } from "./profiles/profile-i18n";
|
||||
|
||||
export const companiesSpanish = {
|
||||
environment: environmentSpanish,
|
||||
certificates: certificatesSpanish,
|
||||
applications: applicationsSpanish,
|
||||
printers: printersSpanish,
|
||||
mail: mailSpanish,
|
||||
mailbox: mailboxSpanish,
|
||||
@ -124,6 +130,9 @@ export const companiesSpanish = {
|
||||
},
|
||||
};
|
||||
export const companiesEnglish = {
|
||||
environment: environmentEnglish,
|
||||
certificates: certificatesEnglish,
|
||||
applications: applicationsEnglish,
|
||||
printers: printersEnglish,
|
||||
mail: mailEnglish,
|
||||
mailbox: mailboxEnglish,
|
||||
|
||||
86
modules/companies/src/web/profiles/environment-i18n.ts
Normal file
86
modules/companies/src/web/profiles/environment-i18n.ts
Normal file
@ -0,0 +1,86 @@
|
||||
export const environmentSpanish = {
|
||||
title: "Entorno actual",
|
||||
description: "Lo que esta persona tiene y necesita en este equipo.",
|
||||
back: "Volver al entorno",
|
||||
manage: "Gestionar",
|
||||
manageSection: "Gestionar {{section}}",
|
||||
equipment: "Equipo y puesto de trabajo",
|
||||
configurations: "Configuraciones del perfil",
|
||||
archived:
|
||||
"Perfil archivado: estas configuraciones conservan su último estado registrado y deben revisarse. No representan un entorno vigente.",
|
||||
personInactive: "La persona está inactiva. Revisa sus configuraciones y accesos.",
|
||||
reviewProfile:
|
||||
"Este perfil necesita revisión. Comprueba persona, equipo y cuenta de inicio de sesión.",
|
||||
device: "Equipo",
|
||||
workstation: "Puesto de trabajo",
|
||||
position: "Puesto de la persona",
|
||||
noStation: "El equipo no pertenece a ningún puesto vigente.",
|
||||
samePerson: "Otros perfiles de esta persona",
|
||||
empty: "No hay configuraciones abiertas en este bloque.",
|
||||
noAttention: "No hay configuraciones pendientes o por revisar en este bloque.",
|
||||
pending: "Por configurar",
|
||||
configured: "Configuradas",
|
||||
removal: "Por retirar",
|
||||
review: "Por revisar",
|
||||
onlyAttention: "Mostrar solo pendientes o por revisar",
|
||||
needsReview: "Requiere revisión",
|
||||
path: "Ruta",
|
||||
drive: "Unidad",
|
||||
default: "Predeterminada",
|
||||
authorization: "Autorización",
|
||||
expiry: "Caducidad",
|
||||
validity: "Vigencia",
|
||||
shared: "Compartido en el puesto",
|
||||
exclusive: "Uso exclusivo en el puesto",
|
||||
retiredAsset: "Activo retirado",
|
||||
profileNotFound: "El perfil no está disponible en esta compañía.",
|
||||
loadError:
|
||||
"No se ha podido cargar el entorno completo. Reintenta para consultar todas sus configuraciones.",
|
||||
loading: "Cargando el entorno…",
|
||||
unknown:
|
||||
"La ausencia de registros no confirma que el equipo esté preparado. Esta ficha muestra información documentada, sin comprobar la configuración real del dispositivo.",
|
||||
unlinkedMail:
|
||||
"Hay {{count}} configuraciones antiguas de correo de esta persona y equipo sin vincular al perfil. Revísalas en Correos en dispositivos de la compañía.",
|
||||
};
|
||||
export const environmentEnglish = {
|
||||
title: "Current environment",
|
||||
description: "What this person has and needs on this device.",
|
||||
back: "Back to environment",
|
||||
manage: "Manage",
|
||||
manageSection: "Manage {{section}}",
|
||||
equipment: "Device and workstation",
|
||||
configurations: "Profile configurations",
|
||||
archived:
|
||||
"Archived profile: configurations retain their last recorded state and need review. This is not a current environment.",
|
||||
personInactive: "This person is inactive. Review their configurations and access.",
|
||||
reviewProfile: "This profile needs review. Check the person, device and sign-in account.",
|
||||
device: "Device",
|
||||
workstation: "Workstation",
|
||||
position: "Person's job title",
|
||||
noStation: "This device does not belong to an active workstation.",
|
||||
samePerson: "Other profiles for this person",
|
||||
empty: "No open configurations in this section.",
|
||||
noAttention: "No pending configurations or reviews in this section.",
|
||||
pending: "To configure",
|
||||
configured: "Configured",
|
||||
removal: "To remove",
|
||||
review: "To review",
|
||||
onlyAttention: "Show only pending or needing review",
|
||||
needsReview: "Needs review",
|
||||
path: "Path",
|
||||
drive: "Drive",
|
||||
default: "Default",
|
||||
authorization: "Authorization",
|
||||
expiry: "Expiry",
|
||||
validity: "Validity",
|
||||
shared: "Shared within workstation",
|
||||
exclusive: "Exclusive within workstation",
|
||||
retiredAsset: "Retired asset",
|
||||
profileNotFound: "This profile is unavailable in this company.",
|
||||
loadError: "Could not load the complete environment. Retry to view all configurations.",
|
||||
loading: "Loading environment…",
|
||||
unknown:
|
||||
"No records does not mean the device is ready. This view shows documented information without inspecting the actual device configuration.",
|
||||
unlinkedMail:
|
||||
"{{count}} legacy mail configurations for this person and device are not linked to the profile. Review them in the company Mail on devices section.",
|
||||
};
|
||||
400
modules/companies/src/web/profiles/work-environment-page.tsx
Normal file
400
modules/companies/src/web/profiles/work-environment-page.tsx
Normal file
@ -0,0 +1,400 @@
|
||||
import { inventoryApi as assetApi, inventoryKeys as assetKeys } from "@metis/assets/client";
|
||||
import type { AssetDTO } from "@metis/assets/common";
|
||||
import {
|
||||
inventoryApi as resourceApi,
|
||||
inventoryKeys as resourceKeys,
|
||||
} from "@metis/resources/client";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CompanyDTO, WorkProfileDTO } from "../../common";
|
||||
import { accessApi, accessKeys } from "../access/access-api";
|
||||
import { accessLabel } from "../access/access-labels";
|
||||
import { applicationApi, applicationKeys } from "../applications/application-api";
|
||||
import { certificateApi, certificateKeys } from "../certificates/certificate-api";
|
||||
import { folderApi, folderKeys } from "../folders/folder-api";
|
||||
import { mailApi, mailKeys } from "../mail/mail-api";
|
||||
import { printerApi, printerKeys } from "../printers/printer-api";
|
||||
|
||||
import {
|
||||
type EnvironmentRow,
|
||||
type EnvironmentSources,
|
||||
type ProfileConfigurationKind,
|
||||
buildEnvironment,
|
||||
} from "./work-environment.model";
|
||||
import { profileApi, profileKeys } from "./work-profile-api";
|
||||
|
||||
function useEnvironmentQueries(companyId: string) {
|
||||
const profiles = useQuery({
|
||||
queryKey: profileKeys.company(companyId),
|
||||
queryFn: () => profileApi.get(companyId),
|
||||
});
|
||||
const assets = useQuery({
|
||||
queryKey: assetKeys.company(companyId),
|
||||
queryFn: () => assetApi.get(companyId),
|
||||
});
|
||||
const resources = useQuery({
|
||||
queryKey: resourceKeys.company(companyId),
|
||||
queryFn: () => resourceApi.get(companyId),
|
||||
});
|
||||
const access = useQuery({
|
||||
queryKey: accessKeys.scope(companyId),
|
||||
queryFn: () => accessApi.request(companyId),
|
||||
});
|
||||
const mail = useQuery({
|
||||
queryKey: mailKeys.company(companyId),
|
||||
queryFn: () => mailApi.get(companyId),
|
||||
});
|
||||
const folders = useQuery({
|
||||
queryKey: folderKeys.company(companyId),
|
||||
queryFn: () => folderApi.get(companyId),
|
||||
});
|
||||
const printers = useQuery({
|
||||
queryKey: printerKeys.company(companyId),
|
||||
queryFn: () => printerApi.get(companyId),
|
||||
});
|
||||
const applications = useQuery({
|
||||
queryKey: applicationKeys.company(companyId),
|
||||
queryFn: () => applicationApi.get(companyId),
|
||||
});
|
||||
const certificates = useQuery({
|
||||
queryKey: certificateKeys.company(companyId),
|
||||
queryFn: () => certificateApi.get(companyId),
|
||||
});
|
||||
const queries = [
|
||||
profiles,
|
||||
assets,
|
||||
resources,
|
||||
access,
|
||||
mail,
|
||||
folders,
|
||||
printers,
|
||||
applications,
|
||||
certificates,
|
||||
];
|
||||
const pending = queries.some((query) => query.isPending);
|
||||
const error = queries.some((query) => query.isError);
|
||||
const retry = () => Promise.all(queries.map((query) => query.refetch()));
|
||||
if (
|
||||
!(
|
||||
profiles.data &&
|
||||
assets.data &&
|
||||
resources.data &&
|
||||
access.data &&
|
||||
mail.data &&
|
||||
folders.data &&
|
||||
printers.data &&
|
||||
applications.data &&
|
||||
certificates.data
|
||||
)
|
||||
) {
|
||||
return { pending, error, retry, data: undefined };
|
||||
}
|
||||
const sources: EnvironmentSources = {
|
||||
assets: assets.data.assets,
|
||||
workstations: assets.data.workstations,
|
||||
resources: resources.data.resources,
|
||||
access: access.data.items,
|
||||
mail: mail.data.configurations,
|
||||
folders: folders.data.mappings,
|
||||
printers: printers.data.configurations,
|
||||
applications: applications.data.configurations,
|
||||
certificates: certificates.data.configurations,
|
||||
};
|
||||
return { pending, error, retry, data: { profiles: profiles.data.profiles, sources } };
|
||||
}
|
||||
export function WorkEnvironmentPage({
|
||||
company,
|
||||
profileId,
|
||||
onManage,
|
||||
onProfile,
|
||||
}: {
|
||||
company: CompanyDTO;
|
||||
profileId: string;
|
||||
onManage: (kind: ProfileConfigurationKind) => void;
|
||||
onProfile: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
const query = useEnvironmentQueries(company.id);
|
||||
if (query.error) {
|
||||
return (
|
||||
<div className="space-y-3" role="alert">
|
||||
<p>{t("environment.loadError")}</p>
|
||||
<Button
|
||||
onClick={() => {
|
||||
query.retry().catch(() => undefined);
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (query.pending || !query.data) {
|
||||
return <p role="status">{t("environment.loading")}</p>;
|
||||
}
|
||||
const profile = query.data.profiles.find(
|
||||
(item) => item.id === profileId && item.companyId === company.id
|
||||
);
|
||||
if (!profile) {
|
||||
return <p role="alert">{t("environment.profileNotFound")}</p>;
|
||||
}
|
||||
return (
|
||||
<EnvironmentContent
|
||||
company={company}
|
||||
key={profile.id}
|
||||
onManage={onManage}
|
||||
onProfile={onProfile}
|
||||
profile={profile}
|
||||
profiles={query.data.profiles}
|
||||
sources={query.data.sources}
|
||||
/>
|
||||
);
|
||||
}
|
||||
function EnvironmentContent({
|
||||
company,
|
||||
profile,
|
||||
profiles,
|
||||
sources,
|
||||
onManage,
|
||||
onProfile,
|
||||
}: {
|
||||
company: CompanyDTO;
|
||||
profile: WorkProfileDTO;
|
||||
profiles: readonly WorkProfileDTO[];
|
||||
sources: EnvironmentSources;
|
||||
onManage: (kind: ProfileConfigurationKind) => void;
|
||||
onProfile: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
const [attention, setAttention] = useState(false);
|
||||
const model = buildEnvironment(profile, sources);
|
||||
const person = company.people.find((item) => item.id === profile.values.personId);
|
||||
const account = sources.access.find((item) => item.id === profile.values.accountId);
|
||||
const others = profiles.filter(
|
||||
(item) =>
|
||||
item.companyId === company.id &&
|
||||
item.values.active &&
|
||||
item.values.personId === profile.values.personId &&
|
||||
item.id !== profile.id
|
||||
);
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<header className="space-y-2">
|
||||
<h2 className="text-2xl font-semibold">
|
||||
{t("environment.title")} · {person?.name ?? profile.values.name}
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{profile.values.name} · {model.host?.values.name ?? profile.values.assetId}
|
||||
</p>
|
||||
<p className="text-sm">{t("environment.description")}</p>
|
||||
</header>
|
||||
{!profile.values.active && (
|
||||
<p className="rounded-md border border-amber-500 p-3 text-sm" role="status">
|
||||
{t("environment.archived")}
|
||||
</p>
|
||||
)}
|
||||
{profile.needsReview && (
|
||||
<p className="text-sm text-amber-700">{t("environment.reviewProfile")}</p>
|
||||
)}
|
||||
{person && !person.active && (
|
||||
<p className="text-sm text-amber-700">{t("environment.personInactive")}</p>
|
||||
)}
|
||||
{others.length > 0 && (
|
||||
<nav aria-label={t("environment.samePerson")} className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm">{t("environment.samePerson")}</span>
|
||||
{others.map((item) => (
|
||||
<Button key={item.id} onClick={() => onProfile(item.id)} size="sm" variant="outline">
|
||||
{item.values.name}
|
||||
</Button>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
<Card>
|
||||
<CardContent className="space-y-4 pt-5">
|
||||
<h3 className="font-semibold">{t("environment.equipment")}</h3>
|
||||
<dl className="grid gap-3 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t("environment.device")}</dt>
|
||||
<dd className="font-medium">{model.host?.values.name ?? profile.values.assetId}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t("profiles.account")}</dt>
|
||||
<dd>
|
||||
{account
|
||||
? accessLabel(account, sources.access, company.people)
|
||||
: t("profiles.noAccount")}
|
||||
</dd>
|
||||
</div>
|
||||
{person?.position && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t("environment.position")}</dt>
|
||||
<dd>{person.position}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t("environment.workstation")}</dt>
|
||||
<dd>
|
||||
{model.stations.map((station) => station.values.name).join(" · ") ||
|
||||
t("environment.noStation")}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{model.host && <HardwareCharacteristics asset={model.host} />}
|
||||
{model.stationAssets.length > 0 && (
|
||||
<ul className="grid gap-2 sm:grid-cols-2">
|
||||
{model.stationAssets.map((asset) => {
|
||||
const shared = model.stations.some((station) =>
|
||||
station.values.sharedAssetIds.includes(asset.id)
|
||||
);
|
||||
return (
|
||||
<li className="rounded-md border p-3 text-sm" key={asset.id}>
|
||||
<span className="font-medium">{asset.values.name}</span>
|
||||
<p className="text-muted-foreground">
|
||||
{asset.specification.templateName} ·{" "}
|
||||
{t(shared ? "environment.shared" : "environment.exclusive")}
|
||||
</p>
|
||||
{asset.values.status === "retired" && (
|
||||
<p className="text-amber-700">{t("environment.retiredAsset")}</p>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{profile.values.notes && (
|
||||
<p className="whitespace-pre-wrap text-sm">{profile.values.notes}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<section aria-label={t("environment.configurations")} className="space-y-4">
|
||||
<dl className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
{(["configured", "pending", "removal", "review"] as const).map((key) => (
|
||||
<div className="rounded-md border p-3" key={key}>
|
||||
<dt className="text-sm text-muted-foreground">{t(`environment.${key}`)}</dt>
|
||||
<dd className="text-2xl font-semibold">{model.counts[key]}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
checked={attention}
|
||||
onChange={(event) => setAttention(event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
{t("environment.onlyAttention")}
|
||||
</label>
|
||||
{model.unlinkedMail > 0 && (
|
||||
<p className="text-sm text-amber-700">
|
||||
{t("environment.unlinkedMail", { count: model.unlinkedMail })}
|
||||
</p>
|
||||
)}
|
||||
<div className="grid items-start gap-4 xl:grid-cols-2">
|
||||
{(["mail", "folders", "printers", "applications", "certificates"] as const).map(
|
||||
(kind) => (
|
||||
<EnvironmentSection
|
||||
attention={attention}
|
||||
key={kind}
|
||||
kind={kind}
|
||||
onManage={() => onManage(kind)}
|
||||
rows={model.rows.filter(
|
||||
(item) =>
|
||||
item.kind === kind && (!attention || item.state !== "configured" || item.review)
|
||||
)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<p className="text-sm text-muted-foreground">{t("environment.unknown")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function HardwareCharacteristics({ asset }: { asset: AssetDTO }) {
|
||||
const { t } = useTranslation("companies");
|
||||
return (
|
||||
<dl className="grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
|
||||
{asset.specification.fields.flatMap((field) => {
|
||||
const value = asset.values.characteristics[field.fieldId];
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return [];
|
||||
}
|
||||
const text = typeof value === "boolean" ? t(value ? "yes" : "no") : String(value);
|
||||
return [
|
||||
<div key={field.fieldId}>
|
||||
<dt className="text-muted-foreground">
|
||||
{field.name}
|
||||
{field.unit ? ` (${field.unit})` : ""}
|
||||
</dt>
|
||||
<dd className="break-words">{text}</dd>
|
||||
</div>,
|
||||
];
|
||||
})}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
function EnvironmentSection({
|
||||
attention,
|
||||
kind,
|
||||
rows,
|
||||
onManage,
|
||||
}: {
|
||||
attention: boolean;
|
||||
kind: ProfileConfigurationKind;
|
||||
rows: readonly EnvironmentRow[];
|
||||
onManage: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-5">
|
||||
<header className="mb-3 flex items-center justify-between gap-3">
|
||||
<h3 className="font-semibold">{t(`${kind}.title`)}</h3>
|
||||
<Button
|
||||
aria-label={t("environment.manageSection", { section: t(`${kind}.title`) })}
|
||||
onClick={onManage}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t("environment.manage")}
|
||||
</Button>
|
||||
</header>
|
||||
{!rows.length && (
|
||||
<p className="py-2 text-sm text-muted-foreground">
|
||||
{t(attention ? "environment.noAttention" : "environment.empty")}
|
||||
</p>
|
||||
)}
|
||||
<ul className="divide-y">
|
||||
{rows.map((row) => (
|
||||
<li className="space-y-2 py-3 text-sm" key={row.id}>
|
||||
<div className="flex flex-wrap justify-between gap-2">
|
||||
<h4 className="font-medium break-words">{row.name}</h4>
|
||||
<span
|
||||
className={row.state === "configured" ? "text-emerald-700" : "text-amber-700"}
|
||||
>
|
||||
{t(`mail.${row.state}`)}
|
||||
</span>
|
||||
</div>
|
||||
<dl>
|
||||
{row.details.map((detail) => (
|
||||
<div className="break-words" key={detail.label}>
|
||||
<dt className="inline text-muted-foreground">{t(detail.label)}: </dt>
|
||||
<dd className="inline">{detail.translated ? t(detail.value) : detail.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{row.review && <p className="text-amber-700">{t("environment.needsReview")}</p>}
|
||||
{row.notes && (
|
||||
<p className="whitespace-pre-wrap text-muted-foreground">{row.notes}</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
266
modules/companies/src/web/profiles/work-environment.model.ts
Normal file
266
modules/companies/src/web/profiles/work-environment.model.ts
Normal file
@ -0,0 +1,266 @@
|
||||
import type { AssetDTO, WorkstationDTO } from "@metis/assets/common";
|
||||
import type { ResourceDTO } from "@metis/resources/common";
|
||||
|
||||
import type {
|
||||
AccessItemDTO,
|
||||
ApplicationConfigurationDTO,
|
||||
CertificateConfigurationDTO,
|
||||
FolderMappingDTO,
|
||||
MailConfigurationDTO,
|
||||
PrinterConfigurationDTO,
|
||||
WorkProfileDTO,
|
||||
} from "../../common";
|
||||
export type ProfileConfigurationKind =
|
||||
| "mail"
|
||||
| "folders"
|
||||
| "printers"
|
||||
| "applications"
|
||||
| "certificates";
|
||||
export type EnvironmentRow = Readonly<{
|
||||
id: string;
|
||||
kind: ProfileConfigurationKind;
|
||||
name: string;
|
||||
state: "pending" | "configured" | "removal_pending";
|
||||
review: boolean;
|
||||
notes: string | null;
|
||||
details: readonly { label: string; value: string; translated?: boolean }[];
|
||||
}>;
|
||||
export type EnvironmentSources = Readonly<{
|
||||
assets: readonly AssetDTO[];
|
||||
resources: readonly ResourceDTO[];
|
||||
workstations: readonly WorkstationDTO[];
|
||||
access: readonly AccessItemDTO[];
|
||||
mail: readonly MailConfigurationDTO[];
|
||||
folders: readonly FolderMappingDTO[];
|
||||
printers: readonly PrinterConfigurationDTO[];
|
||||
applications: readonly ApplicationConfigurationDTO[];
|
||||
certificates: readonly CertificateConfigurationDTO[];
|
||||
}>;
|
||||
function openState(state: string): state is EnvironmentRow["state"] {
|
||||
return state === "pending" || state === "configured" || state === "removal_pending";
|
||||
}
|
||||
function current<T extends { companyId: string; values: { profileId: string; state: string } }>(
|
||||
items: readonly T[],
|
||||
profile: WorkProfileDTO
|
||||
) {
|
||||
return items.filter(
|
||||
(item) =>
|
||||
item.companyId === profile.companyId &&
|
||||
item.values.profileId === profile.id &&
|
||||
openState(item.values.state)
|
||||
);
|
||||
}
|
||||
export function buildEnvironment(profile: WorkProfileDTO, sources: EnvironmentSources) {
|
||||
const resources = new Map(
|
||||
sources.resources
|
||||
.filter((item) => item.companyId === profile.companyId)
|
||||
.map((item) => [item.id, item])
|
||||
);
|
||||
const assets = new Map(
|
||||
sources.assets
|
||||
.filter((item) => item.companyId === profile.companyId)
|
||||
.map((item) => [item.id, item])
|
||||
);
|
||||
const name = (id: string) => resources.get(id)?.values.name ?? id;
|
||||
const rows: EnvironmentRow[] = [];
|
||||
const append = (row: Omit<EnvironmentRow, "state"> & { state: string }) => {
|
||||
if (openState(row.state)) {
|
||||
rows.push({ ...row, state: row.state });
|
||||
}
|
||||
};
|
||||
appendMailRows(profile, sources, resources, append);
|
||||
appendFolderRows(profile, sources, name, append);
|
||||
appendPrinterRows(profile, sources, assets, append);
|
||||
appendApplicationRows(profile, sources, name, append);
|
||||
appendCertificateRows(profile, sources, name, append);
|
||||
const stations = sources.workstations.filter(
|
||||
(item) =>
|
||||
item.companyId === profile.companyId &&
|
||||
item.values.active &&
|
||||
[...item.values.assetIds, ...item.values.sharedAssetIds].includes(profile.values.assetId)
|
||||
);
|
||||
const stationAssets = [
|
||||
...new Set(
|
||||
stations.flatMap((item) => [...item.values.assetIds, ...item.values.sharedAssetIds])
|
||||
),
|
||||
].flatMap((id) => {
|
||||
const asset = assets.get(id);
|
||||
return asset ? [asset] : [];
|
||||
});
|
||||
const unlinkedMail = sources.mail.filter(
|
||||
(item) =>
|
||||
item.companyId === profile.companyId &&
|
||||
!("profileId" in item.values) &&
|
||||
item.values.personId === profile.values.personId &&
|
||||
item.values.assetId === profile.values.assetId &&
|
||||
openState(item.values.state)
|
||||
).length;
|
||||
return {
|
||||
rows,
|
||||
stations,
|
||||
stationAssets,
|
||||
host: assets.get(profile.values.assetId),
|
||||
unlinkedMail,
|
||||
counts: {
|
||||
pending: rows.filter((item) => item.state === "pending").length,
|
||||
configured: rows.filter((item) => item.state === "configured").length,
|
||||
removal: rows.filter((item) => item.state === "removal_pending").length,
|
||||
review: rows.filter((item) => item.review).length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type AddEnvironmentRow = (row: Omit<EnvironmentRow, "state"> & { state: string }) => void;
|
||||
|
||||
function appendMailRows(
|
||||
profile: WorkProfileDTO,
|
||||
sources: EnvironmentSources,
|
||||
resources: ReadonlyMap<string, ResourceDTO>,
|
||||
append: AddEnvironmentRow
|
||||
) {
|
||||
for (const item of sources.mail) {
|
||||
const v = item.values;
|
||||
if (item.companyId !== profile.companyId || !("profileId" in v) || v.profileId !== profile.id) {
|
||||
continue;
|
||||
}
|
||||
const mailbox = resources.get(v.mailboxId);
|
||||
const integration = mailbox?.values.integration;
|
||||
const address =
|
||||
integration?.kind === "mailbox"
|
||||
? mailbox?.values.characteristics[integration.addressFieldId]
|
||||
: undefined;
|
||||
append({
|
||||
id: item.id,
|
||||
kind: "mail",
|
||||
name: typeof address === "string" ? address : v.address,
|
||||
state: v.state,
|
||||
review: item.needsReview,
|
||||
notes: v.notes,
|
||||
details: [{ label: "mail.method", value: `mail.${v.method}`, translated: true }],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function appendFolderRows(
|
||||
profile: WorkProfileDTO,
|
||||
sources: EnvironmentSources,
|
||||
name: (id: string) => string,
|
||||
append: AddEnvironmentRow
|
||||
) {
|
||||
for (const item of current(sources.folders, profile)) {
|
||||
const need = sources.access.find((entry) => entry.id === item.values.needId)?.values;
|
||||
const permissionIds = need?.kind === "need" ? need.permissionIds : [];
|
||||
const permissions = permissionIds
|
||||
.map((id) => {
|
||||
const permission = sources.access.find((entry) => entry.id === id)?.values;
|
||||
return permission?.kind === "permission" ? permission.name : id;
|
||||
})
|
||||
.join(" · ");
|
||||
append({
|
||||
id: item.id,
|
||||
kind: "folders",
|
||||
name: need?.kind === "need" ? name(need.resourceId) : item.values.path,
|
||||
state: item.values.state,
|
||||
review: item.needsReview || !item.permissionsCovered,
|
||||
notes: item.values.notes,
|
||||
details: [
|
||||
{ label: "environment.path", value: item.values.path },
|
||||
{
|
||||
label: "environment.drive",
|
||||
value: item.values.driveLetter ? `${item.values.driveLetter.replace(/:$/, "")}:` : "—",
|
||||
},
|
||||
{ label: "folders.permissions", value: permissions || "—" },
|
||||
{
|
||||
label: "environment.authorization",
|
||||
value: item.permissionsCovered ? "folders.covered" : "folders.uncovered",
|
||||
translated: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function appendPrinterRows(
|
||||
profile: WorkProfileDTO,
|
||||
sources: EnvironmentSources,
|
||||
assets: ReadonlyMap<string, AssetDTO>,
|
||||
append: AddEnvironmentRow
|
||||
) {
|
||||
for (const item of current(sources.printers, profile)) {
|
||||
append({
|
||||
id: item.id,
|
||||
kind: "printers",
|
||||
name: assets.get(item.values.printerId)?.values.name ?? item.values.printerId,
|
||||
state: item.values.state,
|
||||
review: item.needsReview,
|
||||
notes: item.values.notes,
|
||||
details: [
|
||||
{
|
||||
label: "printers.connection",
|
||||
value: `printers.${item.values.connection}`,
|
||||
translated: true,
|
||||
},
|
||||
{
|
||||
label: "environment.default",
|
||||
value: item.values.isDefault ? "yes" : "no",
|
||||
translated: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function appendApplicationRows(
|
||||
profile: WorkProfileDTO,
|
||||
sources: EnvironmentSources,
|
||||
name: (id: string) => string,
|
||||
append: AddEnvironmentRow
|
||||
) {
|
||||
for (const item of current(sources.applications, profile)) {
|
||||
append({
|
||||
id: item.id,
|
||||
kind: "applications",
|
||||
name: name(item.values.resourceId),
|
||||
state: item.values.state,
|
||||
review: item.needsReview,
|
||||
notes: item.values.notes,
|
||||
details: [
|
||||
{
|
||||
label: "applications.delivery",
|
||||
value: `applications.${item.values.delivery}`,
|
||||
translated: true,
|
||||
},
|
||||
...(item.values.version
|
||||
? [{ label: "applications.version", value: item.values.version }]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function appendCertificateRows(
|
||||
profile: WorkProfileDTO,
|
||||
sources: EnvironmentSources,
|
||||
name: (id: string) => string,
|
||||
append: AddEnvironmentRow
|
||||
) {
|
||||
for (const item of current(sources.certificates, profile)) {
|
||||
append({
|
||||
id: item.id,
|
||||
kind: "certificates",
|
||||
name: name(item.values.resourceId),
|
||||
state: item.values.state,
|
||||
review: item.needsReview,
|
||||
notes: item.values.notes,
|
||||
details: [
|
||||
...(item.values.location
|
||||
? [{ label: "certificates.location", value: item.values.location }]
|
||||
: []),
|
||||
...(item.expiresOn ? [{ label: "environment.expiry", value: item.expiresOn }] : []),
|
||||
...(item.expired
|
||||
? [{ label: "environment.validity", value: "certificates.expired", translated: true }]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
348
modules/companies/src/web/profiles/work-environment.test.tsx
Normal file
348
modules/companies/src/web/profiles/work-environment.test.tsx
Normal file
@ -0,0 +1,348 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { inventoryKeys as assetKeys } from "@metis/assets/client";
|
||||
import { AssetSchema, WorkstationSchema } from "@metis/assets/common";
|
||||
import { inventoryKeys as resourceKeys } from "@metis/resources/client";
|
||||
import { ResourceSchema } from "@metis/resources/common";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createInstance } from "i18next";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
|
||||
import {
|
||||
ApplicationConfigurationSchema,
|
||||
CertificateConfigurationSchema,
|
||||
FolderMappingSchema,
|
||||
MailConfigurationSchema,
|
||||
PrinterConfigurationSchema,
|
||||
WorkProfileSchema,
|
||||
} from "../../common";
|
||||
import { accessKeys } from "../access/access-api";
|
||||
import { applicationKeys } from "../applications/application-api";
|
||||
import { certificateKeys } from "../certificates/certificate-api";
|
||||
import { folderKeys } from "../folders/folder-api";
|
||||
import { companiesEnglish, companiesSpanish } from "../i18n";
|
||||
import { mailKeys } from "../mail/mail-api";
|
||||
import { printerKeys } from "../printers/printer-api";
|
||||
|
||||
import { type EnvironmentSources, buildEnvironment } from "./work-environment.model";
|
||||
import { WorkEnvironmentPage } from "./work-environment-page";
|
||||
import { profileKeys } from "./work-profile-api";
|
||||
|
||||
const id = (n: number) => `0199b200-0000-7000-8000-${String(n).padStart(12, "0")}`;
|
||||
const company = {
|
||||
id: id(1),
|
||||
name: "Empresa test",
|
||||
active: true,
|
||||
people: [],
|
||||
contacts: [],
|
||||
sites: [],
|
||||
departments: [],
|
||||
assignments: [],
|
||||
};
|
||||
const actorId = id(2);
|
||||
const recordedAt = "2026-10-06T12:00:00.000Z";
|
||||
function audited<T>(identity: number, values: T) {
|
||||
return {
|
||||
id: id(identity),
|
||||
companyId: company.id,
|
||||
values,
|
||||
history: [{ id: id(identity + 100), values, actorId, actorName: "Técnico", recordedAt }],
|
||||
};
|
||||
}
|
||||
function record<T>(identity: number, values: T) {
|
||||
return { ...audited(identity, values), needsReview: false };
|
||||
}
|
||||
const profile = WorkProfileSchema.parse(
|
||||
record(3, {
|
||||
name: "Perfil Ana",
|
||||
personId: id(4),
|
||||
assetId: id(5),
|
||||
active: true,
|
||||
accountId: null,
|
||||
notes: null,
|
||||
})
|
||||
);
|
||||
const profile2 = WorkProfileSchema.parse(
|
||||
record(6, { ...profile.values, name: "Otro perfil", assetId: id(7) })
|
||||
);
|
||||
const field = {
|
||||
fieldId: id(8),
|
||||
name: "BitLocker",
|
||||
type: "boolean",
|
||||
unit: "",
|
||||
choices: [],
|
||||
required: false,
|
||||
};
|
||||
const host = AssetSchema.parse({
|
||||
...audited(5, {
|
||||
name: "LG Gram",
|
||||
templateId: id(9),
|
||||
personId: null,
|
||||
siteId: null,
|
||||
status: "in_use",
|
||||
notes: "",
|
||||
characteristics: { [field.fieldId]: false },
|
||||
}),
|
||||
specification: { templateId: id(9), templateName: "Portátil", fields: [field] },
|
||||
});
|
||||
const monitor = AssetSchema.parse({
|
||||
...host,
|
||||
id: id(10),
|
||||
values: { ...host.values, name: "Monitor externo" },
|
||||
});
|
||||
const resource = ResourceSchema.parse({
|
||||
...audited(11, {
|
||||
name: "Thunderbird",
|
||||
templateId: id(12),
|
||||
active: true,
|
||||
notes: "",
|
||||
characteristics: {},
|
||||
}),
|
||||
specification: {
|
||||
templateId: id(12),
|
||||
templateName: "Aplicación",
|
||||
fields: [{ ...field, type: "text", name: "Descripción" }],
|
||||
},
|
||||
});
|
||||
const app = ApplicationConfigurationSchema.parse(
|
||||
record(13, {
|
||||
profileId: profile.id,
|
||||
resourceId: resource.id,
|
||||
delivery: "installed",
|
||||
version: "128",
|
||||
state: "pending",
|
||||
notes: "Solicitado por Ana",
|
||||
})
|
||||
);
|
||||
const mail = MailConfigurationSchema.parse({
|
||||
...record(14, {
|
||||
profileId: profile.id,
|
||||
mailboxId: id(15),
|
||||
address: "ana@example.test",
|
||||
method: "imap",
|
||||
state: "configured",
|
||||
notes: null,
|
||||
}),
|
||||
legacy: false,
|
||||
});
|
||||
const secondMail = MailConfigurationSchema.parse({
|
||||
...record(16, {
|
||||
...mail.values,
|
||||
mailboxId: id(17),
|
||||
address: "pedidos@example.test",
|
||||
method: "pop",
|
||||
}),
|
||||
legacy: false,
|
||||
});
|
||||
const certificate = CertificateConfigurationSchema.parse({
|
||||
...record(18, {
|
||||
profileId: profile.id,
|
||||
resourceId: id(19),
|
||||
location: "Firefox",
|
||||
state: "configured",
|
||||
notes: null,
|
||||
}),
|
||||
needsReview: true,
|
||||
expiresOn: "2000-01-01",
|
||||
expired: true,
|
||||
});
|
||||
const printer = PrinterConfigurationSchema.parse(
|
||||
record(20, {
|
||||
profileId: profile.id,
|
||||
printerId: id(21),
|
||||
connection: "usb",
|
||||
isDefault: false,
|
||||
state: "removal_pending",
|
||||
notes: null,
|
||||
})
|
||||
);
|
||||
const folder = FolderMappingSchema.parse({
|
||||
...record(22, {
|
||||
profileId: profile.id,
|
||||
needId: id(23),
|
||||
path: "\\\\SOLETE01\\General",
|
||||
driveLetter: "G",
|
||||
state: "configured",
|
||||
notes: null,
|
||||
}),
|
||||
permissionsCovered: false,
|
||||
});
|
||||
const station = WorkstationSchema.parse(
|
||||
audited(24, {
|
||||
name: "Puesto Ana",
|
||||
active: true,
|
||||
siteId: null,
|
||||
personId: null,
|
||||
assetIds: [host.id],
|
||||
sharedAssetIds: [monitor.id],
|
||||
})
|
||||
);
|
||||
const sources: EnvironmentSources = {
|
||||
assets: [host, monitor],
|
||||
resources: [resource],
|
||||
workstations: [station],
|
||||
access: [],
|
||||
mail: [mail, secondMail],
|
||||
folders: [folder],
|
||||
printers: [printer],
|
||||
applications: [app],
|
||||
certificates: [certificate],
|
||||
};
|
||||
test("entorno: reúne varios correos y configuraciones, distingue pendiente, retirada y revisión", () => {
|
||||
const model = buildEnvironment(profile, sources);
|
||||
assert.equal(model.rows.length, 6);
|
||||
assert.deepEqual(model.counts, { pending: 1, configured: 4, removal: 1, review: 2 });
|
||||
assert.equal(model.rows.filter((item) => item.kind === "mail").length, 2);
|
||||
assert.ok(
|
||||
model.rows.some((item) => item.name === "Thunderbird" && item.notes === "Solicitado por Ana")
|
||||
);
|
||||
assert.ok(model.rows.some((item) => item.kind === "folders" && item.review));
|
||||
assert.ok(
|
||||
model.rows
|
||||
.find((item) => item.kind === "certificates")
|
||||
?.details.some((item) => item.value === "certificates.expired")
|
||||
);
|
||||
});
|
||||
test("entorno: excluye cerradas, otros perfiles, compañías y correo antiguo sin adivinar relaciones", () => {
|
||||
const closed = ApplicationConfigurationSchema.parse({
|
||||
...app,
|
||||
id: id(25),
|
||||
values: { ...app.values, state: "removed" },
|
||||
});
|
||||
const other = ApplicationConfigurationSchema.parse({
|
||||
...app,
|
||||
id: id(26),
|
||||
values: { ...app.values, profileId: profile2.id },
|
||||
});
|
||||
const foreign = ApplicationConfigurationSchema.parse({ ...app, id: id(27), companyId: id(28) });
|
||||
const legacy = MailConfigurationSchema.parse({
|
||||
...mail,
|
||||
id: id(29),
|
||||
legacy: true,
|
||||
values: {
|
||||
personId: profile.values.personId,
|
||||
assetId: host.id,
|
||||
accountId: id(30),
|
||||
address: "viejo@example.test",
|
||||
method: "imap",
|
||||
state: "configured",
|
||||
notes: null,
|
||||
},
|
||||
});
|
||||
const model = buildEnvironment(profile, {
|
||||
...sources,
|
||||
applications: [app, closed, other, foreign],
|
||||
mail: [mail, legacy],
|
||||
});
|
||||
assert.equal(model.rows.filter((item) => item.kind === "applications").length, 1);
|
||||
assert.equal(model.rows.filter((item) => item.kind === "mail").length, 1);
|
||||
assert.equal(model.unlinkedMail, 1);
|
||||
});
|
||||
test("entorno: composición vigente del puesto y datos actuales sin duplicación", () => {
|
||||
const archived = WorkstationSchema.parse({
|
||||
...station,
|
||||
id: id(31),
|
||||
values: { ...station.values, active: false, assetIds: [id(32)] },
|
||||
});
|
||||
const model = buildEnvironment(profile, { ...sources, workstations: [station, archived] });
|
||||
assert.equal(model.host?.id, host.id);
|
||||
assert.deepEqual(
|
||||
model.stationAssets.map((item) => item.id),
|
||||
[host.id, monitor.id]
|
||||
);
|
||||
assert.equal(model.stations.length, 1);
|
||||
const renamed = buildEnvironment(profile, {
|
||||
...sources,
|
||||
resources: [{ ...resource, values: { ...resource.values, name: "Cliente correo" } }],
|
||||
});
|
||||
assert.equal(renamed.rows.find((item) => item.kind === "applications")?.name, "Cliente correo");
|
||||
});
|
||||
async function render(
|
||||
language: string,
|
||||
mode: "ready" | "pending" | "error" | "missing" | "archived" = "ready"
|
||||
) {
|
||||
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 } },
|
||||
});
|
||||
if (mode !== "pending") {
|
||||
client.setQueryData(profileKeys.company(company.id), {
|
||||
profiles:
|
||||
mode === "missing"
|
||||
? []
|
||||
: mode === "archived"
|
||||
? [{ ...profile, values: { ...profile.values, active: false } }]
|
||||
: [profile, profile2],
|
||||
});
|
||||
client.setQueryData(assetKeys.company(company.id), {
|
||||
assets: sources.assets,
|
||||
workstations: sources.workstations,
|
||||
relationships: [],
|
||||
});
|
||||
client.setQueryData(resourceKeys.company(company.id), { resources: sources.resources });
|
||||
client.setQueryData(accessKeys.scope(company.id), { items: [], reviews: [], coverage: [] });
|
||||
client.setQueryData(mailKeys.company(company.id), { configurations: sources.mail });
|
||||
client.setQueryData(folderKeys.company(company.id), { mappings: sources.folders });
|
||||
client.setQueryData(printerKeys.company(company.id), { configurations: sources.printers });
|
||||
client.setQueryData(applicationKeys.company(company.id), {
|
||||
configurations: sources.applications,
|
||||
});
|
||||
client.setQueryData(certificateKeys.company(company.id), {
|
||||
configurations: sources.certificates,
|
||||
});
|
||||
if (mode === "error") {
|
||||
client
|
||||
.getQueryCache()
|
||||
.find({ queryKey: certificateKeys.company(company.id) })
|
||||
?.setState({ status: "error", error: new Error("test") });
|
||||
}
|
||||
}
|
||||
try {
|
||||
return renderToStaticMarkup(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<QueryClientProvider client={client}>
|
||||
<WorkEnvironmentPage
|
||||
company={company}
|
||||
onManage={() => undefined}
|
||||
onProfile={() => undefined}
|
||||
profileId={profile.id}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
</I18nextProvider>
|
||||
);
|
||||
} finally {
|
||||
client.clear();
|
||||
}
|
||||
}
|
||||
test("entorno: ficha es/en con estados y acciones, sin formularios; muestra falso y varios correos", async () => {
|
||||
for (const language of ["es", "en"]) {
|
||||
const html = await render(language);
|
||||
const text = language === "es" ? companiesSpanish : companiesEnglish;
|
||||
assert.ok(html.includes(text.environment.title));
|
||||
assert.ok(html.includes(text.environment.onlyAttention));
|
||||
assert.ok(html.includes(text.environment.samePerson));
|
||||
assert.ok(html.includes("ana@example.test") && html.includes("pedidos@example.test"));
|
||||
assert.ok(html.includes("BitLocker") && html.includes(text.no));
|
||||
assert.ok(html.includes(text.environment.needsReview));
|
||||
assert.ok(!html.includes("<form"));
|
||||
assert.ok(html.includes("Thunderbird") && html.includes("Firefox"));
|
||||
assert.ok(!html.includes("environment.title"));
|
||||
}
|
||||
});
|
||||
test("entorno: carga, fallo parcial sin falsa ficha vacía, perfil inexistente y aviso de archivado", async () => {
|
||||
const pending = await render("es", "pending");
|
||||
assert.ok(pending.includes(companiesSpanish.environment.loading));
|
||||
const error = await render("es", "error");
|
||||
assert.ok(error.includes(companiesSpanish.environment.loadError));
|
||||
assert.ok(!error.includes("Thunderbird"));
|
||||
const missing = await render("es", "missing");
|
||||
assert.ok(missing.includes(companiesSpanish.environment.profileNotFound));
|
||||
const archived = await render("es", "archived");
|
||||
assert.ok(archived.includes(companiesSpanish.environment.archived));
|
||||
});
|
||||
@ -10,14 +10,24 @@ import { useTranslation } from "react-i18next";
|
||||
import type { CompanyDTO } from "../../common";
|
||||
import { accessApi, accessKeys } from "../access/access-api";
|
||||
import { accessLabel } from "../access/access-labels";
|
||||
import { ApplicationConfigurationsPage } from "../applications/application-configurations-page";
|
||||
import { CertificateConfigurationsPage } from "../certificates/certificate-configurations-page";
|
||||
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 type { ProfileConfigurationKind } from "./work-environment.model";
|
||||
import { WorkEnvironmentPage } from "./work-environment-page";
|
||||
import { profileApi, profileKeys } from "./work-profile-api";
|
||||
import { WorkProfileForm } from "./work-profile-form";
|
||||
|
||||
type ProfileSelection = {
|
||||
kind: ProfileConfigurationKind | "environment";
|
||||
profileId: string;
|
||||
fromEnvironment?: boolean;
|
||||
};
|
||||
|
||||
export function WorkProfilesPage({
|
||||
company,
|
||||
csrfToken,
|
||||
@ -26,10 +36,7 @@ export function WorkProfilesPage({
|
||||
csrfToken: string;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("companies");
|
||||
const [configuration, setConfiguration] = useState<{
|
||||
kind: "folders" | "mail" | "printers";
|
||||
profileId: string;
|
||||
}>();
|
||||
const [configuration, setConfiguration] = useState<ProfileSelection>();
|
||||
const [editor, setEditor] = useState<string>();
|
||||
const [view, setView] = useState<string>();
|
||||
const [search, setSearch] = useState("");
|
||||
@ -71,7 +78,14 @@ export function WorkProfilesPage({
|
||||
<ProfileConfigurations
|
||||
company={company}
|
||||
csrfToken={csrfToken}
|
||||
onBack={() => setConfiguration(undefined)}
|
||||
onBack={() =>
|
||||
setConfiguration(
|
||||
configuration.fromEnvironment
|
||||
? { kind: "environment", profileId: configuration.profileId }
|
||||
: undefined
|
||||
)
|
||||
}
|
||||
onSelection={setConfiguration}
|
||||
selection={configuration}
|
||||
/>
|
||||
);
|
||||
@ -138,6 +152,26 @@ export function WorkProfilesPage({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
onClick={() => setConfiguration({ kind: "environment", profileId: item.id })}
|
||||
size="sm"
|
||||
>
|
||||
{t("environment.title")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setConfiguration({ kind: "certificates", profileId: item.id })}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t("certificates.title")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setConfiguration({ kind: "applications", profileId: item.id })}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t("applications.title")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setConfiguration({ kind: "printers", profileId: item.id })}
|
||||
size="sm"
|
||||
@ -261,10 +295,12 @@ function ProfileConfigurations({
|
||||
csrfToken,
|
||||
selection,
|
||||
onBack,
|
||||
onSelection,
|
||||
}: {
|
||||
company: CompanyDTO;
|
||||
csrfToken: string;
|
||||
selection: { kind: "folders" | "mail" | "printers"; profileId: string };
|
||||
selection: ProfileSelection;
|
||||
onSelection: (selection: ProfileSelection) => void;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
@ -272,10 +308,22 @@ function ProfileConfigurations({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button onClick={onBack} variant="outline">
|
||||
{t("profiles.title")}
|
||||
{t(selection.fromEnvironment ? "environment.back" : "profiles.title")}
|
||||
</Button>
|
||||
{selection.kind === "environment" && (
|
||||
<WorkEnvironmentPage
|
||||
company={company}
|
||||
onManage={(kind) =>
|
||||
onSelection({ kind, profileId: selection.profileId, fromEnvironment: true })
|
||||
}
|
||||
onProfile={(profileId) => onSelection({ kind: "environment", profileId })}
|
||||
profileId={selection.profileId}
|
||||
/>
|
||||
)}
|
||||
{selection.kind === "folders" && <FolderMappingsPage {...props} />}
|
||||
{selection.kind === "mail" && <MailConfigurationsPage {...props} />}
|
||||
{selection.kind === "certificates" && <CertificateConfigurationsPage {...props} />}
|
||||
{selection.kind === "applications" && <ApplicationConfigurationsPage {...props} />}
|
||||
{selection.kind === "printers" && <PrinterConfigurationsPage {...props} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -6,7 +6,8 @@
|
||||
"exports": {
|
||||
"./common": "./src/common/index.ts",
|
||||
"./module": "./src/api/module.ts",
|
||||
"./client": "./src/web/index.ts"
|
||||
"./client": "./src/web/index.ts",
|
||||
"./services": "./src/api/services.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
|
||||
@ -0,0 +1,49 @@
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
|
||||
import type { IResourceRepository } from "./resource.repository";
|
||||
export interface IResourceInventoryService {
|
||||
list(company: UniqueID): Promise<
|
||||
readonly {
|
||||
id: UniqueID;
|
||||
active: boolean;
|
||||
usage: "application" | "certificate" | null;
|
||||
expiresOn: string | null;
|
||||
}[]
|
||||
>;
|
||||
}
|
||||
export class ResourceInventoryService implements IResourceInventoryService {
|
||||
constructor(private readonly repository: IResourceRepository) {}
|
||||
async list(company: UniqueID) {
|
||||
const result = await this.repository.get(company);
|
||||
if (!result.isSuccess) {
|
||||
throw new Error("No se pudo consultar el inventario de recursos.");
|
||||
}
|
||||
const expiryFieldId = result.data.catalog.definitions
|
||||
.find((item) => item.stockKey.getOrUndefined() === "field:expires")
|
||||
?.id.toPrimitive();
|
||||
return result.data.resources.map((resource) => {
|
||||
const template = result.data.catalog.definitions.find(
|
||||
(item) => item.id.toPrimitive() === resource.values.toPrimitive().templateId
|
||||
);
|
||||
const data = template?.values.data;
|
||||
const key = template?.stockKey.getOrUndefined();
|
||||
const usage: "application" | "certificate" | null =
|
||||
key === "template:application"
|
||||
? "application"
|
||||
: key === "template:certificate"
|
||||
? "certificate"
|
||||
: data?.kind === "template"
|
||||
? (data.usage ?? null)
|
||||
: null;
|
||||
const expiresOn = expiryFieldId
|
||||
? resource.values.toPrimitive().characteristics[expiryFieldId]
|
||||
: null;
|
||||
return {
|
||||
id: resource.id,
|
||||
active: resource.values.toPrimitive().active,
|
||||
usage,
|
||||
expiresOn: typeof expiresOn === "string" ? expiresOn : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -69,6 +69,7 @@ export class ResourceDefinition extends DomainEntity<DefinitionProps> {
|
||||
const next = values.data;
|
||||
if (
|
||||
previous.kind !== next.kind ||
|
||||
(previous.kind === "template" && next.kind === "template" && previous.usage !== next.usage) ||
|
||||
(previous.kind === "field" &&
|
||||
next.kind === "field" &&
|
||||
(previous.type !== next.type ||
|
||||
|
||||
@ -17,6 +17,7 @@ export const ResourceDefinitionSchema = z.discriminatedUnion("kind", [
|
||||
name: title,
|
||||
description: z.string().max(1000),
|
||||
fields: z.array(TemplateFieldSchema).min(1).max(100),
|
||||
usage: z.enum(["application", "certificate"]).optional(),
|
||||
}),
|
||||
]);
|
||||
export type ResourceDefinitionSpec = z.infer<typeof ResourceDefinitionSchema>;
|
||||
|
||||
@ -87,6 +87,7 @@ export class ResourceDefinitionValues {
|
||||
return (
|
||||
a.kind === "template" &&
|
||||
b.kind === "template" &&
|
||||
a.usage === b.usage &&
|
||||
a.fields.length === b.fields.length &&
|
||||
a.fields.every(
|
||||
(field, index) =>
|
||||
|
||||
@ -5,6 +5,7 @@ import type { Sequelize } from "sequelize";
|
||||
|
||||
import { ResourceUseCases } from "./application/resource.use-cases";
|
||||
import { ResourceCatalogUseCases } from "./application/resource-catalog.use-cases";
|
||||
import { ResourceInventoryService } from "./application/resource-inventory.service";
|
||||
import { seedResourceCatalog } from "./domain/resource-catalog-seed";
|
||||
import { registerResourceRoutes } from "./infrastructure/http/resource.routes";
|
||||
import { registerResourceCatalogRoutes } from "./infrastructure/http/resource-catalog.routes";
|
||||
@ -36,8 +37,10 @@ export async function createResourceModule(database: Sequelize, authenticate: Re
|
||||
}
|
||||
await migrateInventory(database);
|
||||
const catalog = new ResourceCatalogUseCases(repository);
|
||||
const inventory = new ResourceUseCases(new SequelizeResourceRepository(database));
|
||||
const inventoryRepository = new SequelizeResourceRepository(database);
|
||||
const inventory = new ResourceUseCases(inventoryRepository);
|
||||
return {
|
||||
services: { inventory: new ResourceInventoryService(inventoryRepository) },
|
||||
migrateLegacy: () => migrateLegacyResources(database),
|
||||
register: (app: Parameters<typeof registerResourceCatalogRoutes>[0]) => {
|
||||
registerResourceCatalogRoutes(app, catalog, authenticate);
|
||||
|
||||
1
modules/resources/src/api/services.ts
Normal file
1
modules/resources/src/api/services.ts
Normal file
@ -0,0 +1 @@
|
||||
export type { IResourceInventoryService } from "./application/resource-inventory.service";
|
||||
@ -48,6 +48,7 @@ export function CatalogTemplateForm({
|
||||
kind: "template",
|
||||
name: copy ? t("copyName", { name: copy.values.name }) : (current?.name ?? ""),
|
||||
description: current?.description ?? "",
|
||||
usage: templateUsage(source),
|
||||
fields: current?.fields.map((field) => ({ ...field })) ?? [],
|
||||
},
|
||||
});
|
||||
@ -80,6 +81,28 @@ export function CatalogTemplateForm({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-usage`}>{t("templateUsage")}</Label>
|
||||
<SelectWithAction
|
||||
disabled={!!item || mutation.isPending}
|
||||
id={`${id}-usage`}
|
||||
label={t("templateUsage")}
|
||||
onValueChange={(value) => {
|
||||
if (value === "application" || value === "certificate") {
|
||||
form.setValue("usage", value);
|
||||
} else {
|
||||
form.setValue("usage", undefined);
|
||||
}
|
||||
}}
|
||||
options={["general", "application", "certificate"].map((value) => ({
|
||||
value,
|
||||
label: t(`usage.${value}`),
|
||||
}))}
|
||||
placeholder={t("choose")}
|
||||
value={form.watch("usage") ?? "general"}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t("templateUsageHelp")}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-field`}>{t("selectField")}</Label>
|
||||
<SelectWithAction
|
||||
@ -196,3 +219,13 @@ export function CatalogTemplateForm({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function templateUsage(source: CatalogItemDTO | undefined) {
|
||||
if (source?.builtinKey === "template:application") {
|
||||
return "application";
|
||||
}
|
||||
if (source?.builtinKey === "template:certificate") {
|
||||
return "certificate";
|
||||
}
|
||||
return source?.values.kind === "template" ? source.values.usage : undefined;
|
||||
}
|
||||
|
||||
@ -12,6 +12,10 @@ export const resourcesSpanish = {
|
||||
newField: "Nuevo campo",
|
||||
editField: "Editar campo",
|
||||
newTemplate: "Nueva plantilla",
|
||||
templateUsage: "Uso de la plantilla",
|
||||
templateUsageHelp:
|
||||
"Permite utilizar sus recursos en perfiles de trabajo. El uso queda fijo tras el alta.",
|
||||
usage: { general: "General", application: "Aplicación", certificate: "Certificado digital" },
|
||||
editTemplate: "Editar plantilla",
|
||||
name: "Nombre",
|
||||
description: "Descripción (opcional)",
|
||||
@ -92,7 +96,7 @@ export const resourcesSpanish = {
|
||||
FIELD_UNAVAILABLE:
|
||||
"Solo puedes añadir campos activos de recursos del catálogo global o de esta compañía.",
|
||||
FIELD_TYPE_FIXED:
|
||||
"El tipo de dato no se puede cambiar ni se pueden eliminar valores existentes de una lista.",
|
||||
"El tipo de dato y el uso de la plantilla quedan fijos; tampoco se pueden eliminar valores existentes de una lista.",
|
||||
BUILTIN_PROTECTED: "Las definiciones de serie no se pueden modificar.",
|
||||
DEFINITION_NOT_FOUND: "La definición no está disponible en este ámbito.",
|
||||
COMPANY_NOT_FOUND: "No se ha encontrado la compañía.",
|
||||
@ -114,6 +118,9 @@ export const resourcesEnglish = {
|
||||
newField: "New field",
|
||||
editField: "Edit field",
|
||||
newTemplate: "New template",
|
||||
templateUsage: "Template use",
|
||||
templateUsageHelp: "Enables resources in work profiles. The use is fixed after creation.",
|
||||
usage: { general: "General", application: "Application", certificate: "Digital certificate" },
|
||||
editTemplate: "Edit template",
|
||||
name: "Name",
|
||||
description: "Description (optional)",
|
||||
@ -189,7 +196,8 @@ export const resourcesEnglish = {
|
||||
INVALID_FIELDS: "The template needs distinct valid fields.",
|
||||
NAME_EXISTS: "A definition of this type already has this name.",
|
||||
FIELD_UNAVAILABLE: "Only active global or company fields can be added.",
|
||||
FIELD_TYPE_FIXED: "The data type cannot change and existing list values cannot be removed.",
|
||||
FIELD_TYPE_FIXED:
|
||||
"The data type and template use cannot change; existing list values cannot be removed.",
|
||||
BUILTIN_PROTECTED: "Built-in definitions cannot be edited.",
|
||||
DEFINITION_NOT_FOUND: "This definition is unavailable in this scope.",
|
||||
COMPANY_NOT_FOUND: "Company not found.",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
export { catalogKeys } from "./catalog-api";
|
||||
export { catalogApi, catalogKeys } from "./catalog-api";
|
||||
export { ResourceCatalogPage } from "./catalog-page";
|
||||
export { resourcesEnglish, resourcesSpanish } from "./i18n";
|
||||
export { inventoryKeys } from "./inventory-api";
|
||||
export { inventoryApi, inventoryKeys } from "./inventory-api";
|
||||
export { ResourceEditor } from "./resource-editor";
|
||||
export { ResourcesPage } from "./resources-page";
|
||||
|
||||
@ -17,7 +17,7 @@ export function ResourceEditor({
|
||||
companyId: string;
|
||||
csrfToken: string;
|
||||
resourceId?: string | undefined;
|
||||
initialKind?: "mailbox" | "access" | undefined;
|
||||
initialKind?: "mailbox" | "access" | "application" | "certificate" | undefined;
|
||||
initialAddress?: string;
|
||||
initialAccess?:
|
||||
| { resourceTypeId: string; systemId: string | null; environmentId: string | null }
|
||||
|
||||
@ -44,7 +44,7 @@ export function ResourceForm({
|
||||
items: readonly CatalogItemDTO[];
|
||||
onSaved: (id: string) => void;
|
||||
allowQuickCreate?: boolean;
|
||||
initialKind?: "mailbox" | "access" | undefined;
|
||||
initialKind?: "mailbox" | "access" | "application" | "certificate" | undefined;
|
||||
initialAddress?: string;
|
||||
initialIntegration?: ResourceInput["integration"];
|
||||
}) {
|
||||
@ -74,7 +74,14 @@ export function ResourceForm({
|
||||
),
|
||||
});
|
||||
const values = form.watch();
|
||||
const templates = items.filter((item) => item.values.kind === "template" && item.effectiveActive);
|
||||
const templates = items.filter(
|
||||
(item) =>
|
||||
item.values.kind === "template" &&
|
||||
item.effectiveActive &&
|
||||
((initialKind !== "application" && initialKind !== "certificate") ||
|
||||
item.builtinKey === `template:${initialKind}` ||
|
||||
item.values.usage === initialKind)
|
||||
);
|
||||
const template = templates.find((item) => item.id === values.templateId);
|
||||
const fields = resourceFields(resource, template, items);
|
||||
const mutation = useMutation({
|
||||
@ -88,6 +95,8 @@ export function ResourceForm({
|
||||
["mail-configurations"],
|
||||
["access"],
|
||||
["folder-mappings"],
|
||||
["application-configurations"],
|
||||
["certificate-configurations"],
|
||||
].map((queryKey) => client.invalidateQueries({ queryKey }))
|
||||
);
|
||||
if (mounted.current) {
|
||||
@ -316,7 +325,7 @@ function requireMailboxAddress(
|
||||
function resourceDefaults(
|
||||
items: readonly CatalogItemDTO[],
|
||||
resource: ResourceDTO | undefined,
|
||||
initialKind: "mailbox" | "access" | undefined,
|
||||
initialKind: "mailbox" | "access" | "application" | "certificate" | undefined,
|
||||
initialAddress: string,
|
||||
initialIntegration: ResourceInput["integration"]
|
||||
): ResourceInput {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user