feat: implement software installation management

- Add SoftwareInstallationValues value object for encapsulating software installation data.
- Create HTTP routes for managing software installations, including GET, POST, and DELETE operations.
- Implement Sequelize repository for software installations, handling database interactions.
- Add migration script for creating necessary database tables for software installations.
- Create persistence mapper for transforming between domain models and database rows.
- Define snapshot schema for software installations to ensure data consistency.
- Introduce common schemas for software installations to validate API requests and responses.
- Develop UI components for displaying and managing software installations, including history dialogs and forms.
- Implement API client for interacting with software installation endpoints.
- Write tests for software installations panel and form components to ensure functionality.
This commit is contained in:
David Arranz 2026-10-12 20:31:21 +02:00
parent b183627df0
commit 3ceac47cd6
46 changed files with 1993 additions and 51 deletions

View File

@ -50,6 +50,7 @@ import { verifyRelationships } from "./relationships.integration";
import { verifyRemoteConnections } from "./remote.integration";
import { verifyResourceMigration } from "./resource-migration.integration";
import { verifyResources } from "./resources.integration";
import { verifySoftwareInstallations } from "./software-installations.integration";
import { verifySupport } from "./support.integration";
import { verifyTelework } from "./telework.integration";
import { verifyTickets } from "./tickets.integration";
@ -888,6 +889,12 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
secondCompany.id,
database
);
await verifySoftwareInstallations(
(...args) => operator.request(...args),
customer.id,
secondCompany.id,
database
);
await verifyApplications(
(...args) => operator.request(...args),
customer.id,

View File

@ -58,8 +58,8 @@ export async function verifyResources(
const initial = await read(global);
assert.equal(initial.items.length, 27);
assert.equal(initial.items.filter((item) => item.values.kind === "template").length, 7);
assert.equal(initial.items.length, 28);
assert.equal(initial.items.filter((item) => item.values.kind === "template").length, 8);
assert.ok(initial.items.every((item) => item.builtin && item.id[14] === "7"));
assert.ok(
initial.items.every((item) =>

View File

@ -0,0 +1,112 @@
import assert from "node:assert/strict";
import { InventoryResponseSchema } from "@metis/assets/common";
import {
SoftwareInstallationResponseSchema,
SoftwareInstallationsResponseSchema,
} from "@metis/companies/common";
import { CatalogResponseSchema, ResourceResponseSchema } from "@metis/resources/common";
import type { Sequelize } from "sequelize";
import { migrateSoftwareInstallations } from "../../../modules/companies/src/api/infrastructure/persistence/software-installation-migration";
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
export async function verifySoftwareInstallations(
request: Request,
companyId: string,
otherId: string,
database: Sequelize
) {
await migrateSoftwareInstallations(database);
const inventory = InventoryResponseSchema.parse(
await (await request(`/assets/${companyId}`)).json()
);
const host = inventory.assets.find((asset) => asset.values.status !== "retired");
const otherHost = InventoryResponseSchema.parse(
await (await request(`/assets/${otherId}`)).json()
).assets[0];
const catalog = CatalogResponseSchema.parse(
await (await request(`/resource-catalog/${companyId}`)).json()
);
const template = catalog.items.find((item) => item.builtinKey === "template:os");
const application = catalog.items.find((item) => item.builtinKey === "template:application");
const certificate = catalog.items.find((item) => item.builtinKey === "template:certificate");
assert.ok(host && otherHost && template && application && certificate);
assert.ok(template.values.kind === "template" && template.values.usage === "os");
const addResource = async (templateId: string, name: string) => {
const response = await request(`/resources/${companyId}`, {
values: { name, templateId, active: true, notes: "", characteristics: {} },
});
assert.equal(response.status, 201, await response.clone().text());
return ResourceResponseSchema.parse(await response.json()).resource;
};
const os = await addResource(template.id, "Windows 11 Pro test");
const app = await addResource(application.id, "Office equipo test");
const cert = await addResource(certificate.id, "Certificado no instalable como software");
const path = `/software-installations/${companyId}`;
const values = {
assetId: host.id,
resourceId: os.id,
version: "24H2",
state: "configured",
notes: null,
};
const foreign = await request(path, { values: { ...values, assetId: otherHost.id } });
assert.equal(foreign.status, 404);
assert.equal((await request(path, { values: { ...values, resourceId: cert.id } })).status, 404);
assert.equal((await request(path, { values: { ...values, profileId: host.id } })).status, 400);
assert.equal((await request(path, { values }, "https://other.example")).status, 403);
assert.equal((await request(path, { values }, undefined, "a".repeat(64))).status, 403);
const responses = await Promise.all([request(path, { values }), request(path, { values })]);
assert.deepEqual(responses.map((response) => response.status).sort(), [201, 409]);
const success = responses.find((response) => response.status === 201);
assert.ok(success);
const created = SoftwareInstallationResponseSchema.parse(await success.json()).configuration;
const save = async (data: typeof values) => {
const response = await request(`${path}/${created.id}/update`, { values: data });
assert.equal(response.status, 200, await response.clone().text());
return SoftwareInstallationResponseSchema.parse(await response.json()).configuration;
};
assert.deepEqual(await save(values), created);
const updated = await save({ ...values, version: "25H2" });
assert.equal(updated.history.length, 2);
assert.equal(updated.history[0]?.values.version, "24H2");
assert.equal(updated.history[1]?.values.version, "25H2");
assert.equal((await request(path, { values: { ...values, resourceId: app.id } })).status, 201);
assert.equal(
(await request(`/software-installations/${otherId}/${created.id}/update`, { values })).status,
404
);
assert.equal(
SoftwareInstallationsResponseSchema.parse(
await (await request(`/software-installations/${otherId}`)).json()
).configurations.length,
0
);
const deactivate = await request(`/resources/${companyId}/${os.id}/update`, {
values: { ...os.values, active: false },
});
assert.equal(deactivate.status, 200);
const listed = SoftwareInstallationsResponseSchema.parse(await (await request(path)).json());
assert.equal(listed.configurations.find((item) => item.id === created.id)?.needsReview, true);
await save({ ...values, version: "25H2", state: "removal_pending" });
await save({ ...values, version: "25H2", state: "removed" });
assert.equal((await request(`${path}/${created.id}/delete`, {})).status, 200);
assert.equal((await request(`${path}/${created.id}/delete`, {})).status, 404);
}

View File

@ -14,7 +14,7 @@ Pendiente de configurar documenta una necesidad. Configurado documenta que se ha
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.
Proveedor, URL y descripción pertenecen al recurso común. Forma de uso, versión y estado pertenecen al perfil. Las [instalaciones del ordenador](software-del-ordenador.md) documentan por separado el software para el equipo y su sistema operativo actual; no se duplican por cada 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

View File

@ -44,3 +44,6 @@ La relación opcional `personId` identifica al responsable del puesto, no a todo
La ficha reúne únicamente perfiles del ordenador principal, incluidos los archivados cuando corresponda. Los periféricos y activos compartidos no aportan perfiles. El entorno actual y la consulta del perfil utilizan el mismo criterio. Cambiar el ordenador principal no reasigna perfiles automáticamente: los perfiles vigentes que dejan de tener un puesto activo requieren revisión.
La ficha consulta también el [software del ordenador](software-del-ordenador.md), incluido el sistema operativo actual, por `computerAssetId`. Las instalaciones permanecen vinculadas al activo al cambiar el ordenador principal.

View File

@ -0,0 +1,31 @@
# Software del ordenador
## Uso
La ficha de un puesto muestra **Software del ordenador**, asociado a su ordenador principal. Permite registrar el sistema operativo actual y las aplicaciones instaladas para el equipo, sin exigir persona ni perfil. Dos perfiles del mismo equipo consultan la misma instalación; cambiar el responsable no cambia el software. Al sustituir el ordenador principal, la ficha consulta las instalaciones del equipo nuevo y conserva las del anterior.
El sistema operativo es un recurso de la plantilla global **Sistema operativo**, con proveedor, edición y descripción; las copias y plantillas propias admiten uso explícito `os`. Las aplicaciones usan el recurso de uso `application`. La versión/build concreta pertenece a la instalación; el producto y edición pertenecen al recurso. Se aceptan varias instalaciones de sistemas operativos distintos (por ejemplo, arranque dual); no se deduce su tipo por el nombre ni se guarda el SO actual en las características del activo. El SO de fábrica sigue describiendo la compra original.
El alta y la edición utilizan modal. Hay altas rápidas para sistema operativo y aplicación reutilizando el editor de recursos sin un tercer nivel de formularios. Cancelar el hijo conserva el padre; un recurso ya guardado permanece aunque se cancele la instalación. El listado reutiliza EntityListHeader, EntityTable, acciones por fila y paginación compartida, con búsqueda, columnas, ordenación, inclusión de cerradas y limpiar filtros. Recurso enlaza a su ficha.
## Relación con perfiles
La instalación del ordenador y la disponibilidad de una aplicación para una persona son relaciones distintas. El bloque de aplicaciones del perfil conserva sus registros, forma de uso y versiones; puede representar aplicaciones web o instalaciones propias de ese usuario. No se generan instalaciones de equipo a partir de registros antiguos porque no documentan ese ámbito. Tampoco se crean aplicaciones en todos los perfiles por registrar software en el equipo. No se instalan programas ni se asignan o consumen licencias automáticamente.
## Reglas
La pareja ordenador/recurso es fija y solo admite una instalación abierta. Una cerrada permite un nuevo registro. Se comparte el ciclo ya existente: pendiente → configurado o nunca configurado; configurado → pendiente de retirar → retirado. Los estados terminales no se editan. Las notas pueden completarse mientras se revisa o retira una instalación.
El servidor comprueba pertenencia a compañía, tipo de recurso, disponibilidad del activo y actividad del recurso. Las altas o reconfiguraciones pendientes/configuradas exigen referencias disponibles; retirar y completar notas sigue siendo posible si el activo está retirado o el recurso inactivo. Se conserva historial y aparece un aviso de revisión. Eliminar corrige un alta por error y elimina también sus revisiones, con confirmación explícita en la interfaz.
## Persistencia y API
SoftwareInstallation es un agregado, con SoftwareInstallationValues y revisiones con UUID propio. `software_installations` y `software_installation_revisions` guardan columnas tradicionales: compañía, activo, recurso, versión, estado, notas, actor y fecha. Claves foráneas protegen referencias, una clave nullable única protege la pareja abierta y el índice de revisiones protege su orden. No se persiste JSON.
Migración explícita e idempotente al inicializar el módulo. Las escrituras se serializan con el bloqueo de compañía y guardan raíz/revisiones dentro de la misma transacción. Una actualización semánticamente equivalente no añade revisión. El mapper comprueba identidad, pareja fija, fechas, transiciones, última revisión y clave abierta.
`GET /api/software-installations/:companyId` consulta todas las instalaciones de la compañía; la ficha filtra por ordenador. `POST` crea; `POST /:companyId/:id/update` edita; `POST /:companyId/:id/delete` elimina. Sesión obligatoria, validación estricta, origen y CSRF en escrituras; actor tomado de la sesión. La auditoría central continúa como entrega independiente.
## Verificación
Pruebas de dominio y persistencia: referencias, SO/aplicaciones, duplicados, ámbito, identidad fija, idempotencia, versiones, revisión, transiciones e historial corrupto. Pruebas HTTP con MariaDB: migración repetida, concurrencia, CSRF/origen, compañía ajena, versiones históricas, recursos inactivos, retirada y eliminación. Pruebas de interfaz es/en: filtrado por ordenador, enlaces, versiones, ausencia de formularios incrustados y ayuda.

View File

@ -10,7 +10,7 @@
},
"scripts": {
"typecheck": "tsc -p tsconfig.json",
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/companies/shared/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/shared/access.test.tsx src/web/mail-configurations/shared/mail.test.tsx src/api/domain/work-profile.test.ts src/web/work-profiles/shared/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printer-configurations/shared/printer.test.tsx src/api/domain/application-configuration.test.ts src/web/application-configurations/shared/application.test.tsx src/api/domain/certificate-configuration.test.ts src/web/certificate-configurations/shared/certificate.test.tsx src/web/work-environments/shared/work-environment.test.tsx src/api/domain/telework-authorization.test.ts src/web/telework-authorizations/shared/telework.test.tsx src/api/domain/remote-connection.test.ts src/api/domain/process.test.ts src/web/remote-connections/shared/remote.test.tsx src/web/processes/shared/processes.test.tsx src/api/domain/ticket.test.ts src/web/tickets/shared/tickets.test.tsx src/api/domain/intervention-template.test.ts src/web/intervention-templates/shared/templates.test.tsx src/api/domain/intervention.test.ts src/web/interventions/shared/interventions.test.tsx src/web/shared/initials-avatar.test.tsx src/api/infrastructure/company-logo-processor.test.ts src/web/companies/shared/company-avatar.test.tsx src/web/shared/client-contracts.test.ts src/web/workstations/detail/helpers/workstation-relations.test.ts src/web/workstations/detail/ui/workstation-detail.test.tsx src/web/workspace/shared/workspace-routes.test.ts src/web/workspace/view/helpers/sidebar-preference.test.ts"
"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/companies/shared/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/shared/access.test.tsx src/web/mail-configurations/shared/mail.test.tsx src/api/domain/work-profile.test.ts src/web/work-profiles/shared/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printer-configurations/shared/printer.test.tsx src/api/domain/application-configuration.test.ts src/web/application-configurations/shared/application.test.tsx src/api/domain/certificate-configuration.test.ts src/web/certificate-configurations/shared/certificate.test.tsx src/web/work-environments/shared/work-environment.test.tsx src/api/domain/telework-authorization.test.ts src/web/telework-authorizations/shared/telework.test.tsx src/api/domain/remote-connection.test.ts src/api/domain/process.test.ts src/web/remote-connections/shared/remote.test.tsx src/web/processes/shared/processes.test.tsx src/api/domain/ticket.test.ts src/web/tickets/shared/tickets.test.tsx src/api/domain/intervention-template.test.ts src/web/intervention-templates/shared/templates.test.tsx src/api/domain/intervention.test.ts src/web/interventions/shared/interventions.test.tsx src/web/shared/initials-avatar.test.tsx src/api/infrastructure/company-logo-processor.test.ts src/web/companies/shared/company-avatar.test.tsx src/web/shared/client-contracts.test.ts src/web/workstations/detail/helpers/workstation-relations.test.ts src/web/workstations/detail/ui/workstation-detail.test.tsx src/web/workspace/shared/workspace-routes.test.ts src/web/workspace/view/helpers/sidebar-preference.test.ts src/api/domain/software-installation.test.ts src/web/software-installations/shared/software.test.tsx"
},
"dependencies": {
"@metis/core": "workspace:*",

View File

@ -13,7 +13,7 @@ export type ApplicationContext = Readonly<{
resources: readonly {
id: UniqueID;
active: boolean;
usage: "application" | "certificate" | "support" | null;
usage: "application" | "certificate" | "support" | "os" | null;
}[];
actors: ReadonlyMap<string, string>;
}>;

View File

@ -14,7 +14,7 @@ export type CertificateContext = Readonly<{
resources: readonly {
id: UniqueID;
active: boolean;
usage: "application" | "certificate" | "support" | null;
usage: "application" | "certificate" | "support" | "os" | null;
expiresOn: string | null;
}[];
actors: ReadonlyMap<string, string>;

View File

@ -0,0 +1,50 @@
import { Result } from "@repo/rdx-utils";
import type { SoftwareInstallation } from "../domain/software-installation.aggregate";
import type { SoftwareInstallationInput } from "../domain/software-installation-values";
import { SoftwareInstallationError } from "../domain/software-installation-values";
import { SoftwareInstallationValues } from "../domain/value-objects/software-installation-values.vo";
import type { SoftwareContext } from "./software-installation.repository";
import { softwareAvailable, softwareReferences } from "./software-installation-policy";
export function mapSoftwareInput(
input: SoftwareInstallationInput,
context: SoftwareContext,
previous?: SoftwareInstallation
) {
const parsed = SoftwareInstallationValues.create(input);
if (!parsed.isSuccess) {
return parsed;
}
const values = parsed.data;
if (previous && !previous.values.sameTarget(values)) {
return Result.fail(new SoftwareInstallationError("SOFTWARE_TARGET_IMMUTABLE"));
}
if (!softwareReferences(values, context)) {
return Result.fail(new SoftwareInstallationError("SOFTWARE_REFERENCE_NOT_FOUND"));
}
const reconfiguring =
!previous ||
previous.values.state !== values.state ||
previous.values.version.getOrUndefined() !== values.version.getOrUndefined();
if (
["pending", "configured"].includes(values.state) &&
reconfiguring &&
!softwareAvailable(values, context)
) {
return Result.fail(new SoftwareInstallationError("SOFTWARE_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 SoftwareInstallationError("SOFTWARE_ALREADY_OPEN"));
}
return parsed;
}

View File

@ -0,0 +1,25 @@
import type { SoftwareInstallationValues } from "../domain/value-objects/software-installation-values.vo";
import type { SoftwareContext } from "./software-installation.repository";
export function softwareReferences(values: SoftwareInstallationValues, context: SoftwareContext) {
return (
context.assets.some((item) => item.id.equals(values.assetId)) &&
context.resources.some(
(item) =>
item.id.equals(values.resourceId) && (item.usage === "application" || item.usage === "os")
)
);
}
export function softwareAvailable(values: SoftwareInstallationValues, context: SoftwareContext) {
return (
context.assets.some((item) => item.id.equals(values.assetId) && item.available) &&
context.resources.some(
(item) =>
item.id.equals(values.resourceId) &&
item.active &&
(item.usage === "application" || item.usage === "os")
)
);
}

View File

@ -0,0 +1,27 @@
import type { SoftwareInstallationDTO } from "../../common/software-installations";
import type { SoftwareInstallation } from "../domain/software-installation.aggregate";
import type { SoftwareContext } from "./software-installation.repository";
import { softwareAvailable } from "./software-installation-policy";
export function buildSoftwareResponse(
item: SoftwareInstallation,
context: SoftwareContext
): SoftwareInstallationDTO {
return {
id: item.id.toPrimitive(),
companyId: item.companyId.toPrimitive(),
values: item.values.toPrimitive(),
needsReview:
!item.values.closed &&
(!softwareAvailable(item.values, context) || item.values.state === "removal_pending"),
history: item.history.map((revision) => ({
id: revision.id.toPrimitive(),
actorId: revision.actorId.toPrimitive(),
actorName:
context.actors.get(revision.actorId.toPrimitive()) ?? revision.actorId.toPrimitive(),
recordedAt: revision.recordedAt,
values: revision.values.toPrimitive(),
})),
};
}

View File

@ -0,0 +1,30 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Result } from "@repo/rdx-utils";
import type { SoftwareInstallation } from "../domain/software-installation.aggregate";
import type { SoftwareInstallationError } from "../domain/software-installation-values";
export type SoftwareContext = Readonly<{
configurations: readonly SoftwareInstallation[];
assets: readonly { id: UniqueID; available: boolean }[];
resources: readonly {
id: UniqueID;
active: boolean;
usage: "application" | "certificate" | "support" | "os" | null;
}[];
actors: ReadonlyMap<string, string>;
}>;
export interface ISoftwareInstallationRepository {
remove(company: UniqueID, id: UniqueID): Promise<Result<void, SoftwareInstallationError>>;
get(company: UniqueID): Promise<Result<SoftwareContext, SoftwareInstallationError>>;
save(
company: UniqueID,
action: (context: SoftwareContext) => Result<SoftwareInstallation, SoftwareInstallationError>
): Promise<
Result<
{ configuration: SoftwareInstallation; context: SoftwareContext },
SoftwareInstallationError
>
>;
}

View File

@ -0,0 +1,72 @@
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { SoftwareInstallation } from "../domain/software-installation.aggregate";
import type { SoftwareInstallationInput } from "../domain/software-installation-values";
import { SoftwareInstallationError } from "../domain/software-installation-values";
import type { ISoftwareInstallationRepository } from "./software-installation.repository";
import { mapSoftwareInput } from "./software-installation-input.mapper";
export class SoftwareInstallationUseCases {
constructor(private readonly repository: ISoftwareInstallationRepository) {}
remove(companyId: string, id: string) {
const company = UniqueID.create(companyId);
const identity = UniqueID.create(id);
if (!(company.isSuccess && identity.isSuccess)) {
return Promise.resolve(Result.fail(new SoftwareInstallationError("INVALID_REQUEST")));
}
return this.repository.remove(company.data, identity.data);
}
get(companyId: string) {
const company = UniqueID.create(companyId);
return company.isSuccess
? this.repository.get(company.data)
: Promise.resolve(Result.fail(new SoftwareInstallationError("INVALID_REQUEST")));
}
save(
companyId: string,
id: string | undefined,
input: SoftwareInstallationInput,
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 SoftwareInstallationError("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 SoftwareInstallationError("SOFTWARE_CONFIGURATION_NOT_FOUND"));
}
const values = mapSoftwareInput(input, context, previous);
if (!values.isSuccess) {
return values;
}
const now = new Date().toISOString();
const candidate = previous
? previous.update(values.data, actor.data, now)
: SoftwareInstallation.create(company.data, values.data, actor.data, now);
if (!candidate.isSuccess) {
return candidate;
}
return candidate;
});
}
}

View File

@ -0,0 +1,32 @@
import type { UniqueID } from "@repo/rdx-ddd";
import { DomainEntity } from "@repo/rdx-ddd";
import type { SoftwareInstallationValues } from "./value-objects/software-installation-values.vo";
export type SoftwareInstallationRevisionProps = Readonly<{
values: SoftwareInstallationValues;
actorId: UniqueID;
recordedAt: string;
}>;
export class SoftwareInstallationRevision extends DomainEntity<SoftwareInstallationRevisionProps> {
static create(props: SoftwareInstallationRevisionProps) {
return new SoftwareInstallationRevision(Object.freeze({ ...props }));
}
static rehydrate(props: SoftwareInstallationRevisionProps, id: UniqueID) {
return new SoftwareInstallationRevision(Object.freeze({ ...props }), id);
}
get values() {
return this.props.values;
}
get actorId() {
return this.props.actorId;
}
get recordedAt() {
return this.props.recordedAt;
}
}

View File

@ -0,0 +1,27 @@
import { z } from "zod/v4";
import { ConfigurationSchema } from "./access/access-definition";
export const SoftwareInstallationInputSchema = z.strictObject({
assetId: z.uuid(),
resourceId: z.uuid(),
version: z.string().trim().max(100).nullable(),
state: ConfigurationSchema,
notes: z.string().trim().max(4000).nullable(),
});
export type SoftwareInstallationInput = z.infer<typeof SoftwareInstallationInputSchema>;
export class SoftwareInstallationError extends Error {
constructor(public readonly code: string) {
super(code);
}
}
export const SOFTWARE_STATE_TRANSITIONS = {
pending: ["pending", "configured", "never_configured"],
configured: ["configured", "removal_pending"],
removal_pending: ["removal_pending", "removed"],
removed: ["removed"],
never_configured: ["never_configured"],
} as const;

View File

@ -0,0 +1,102 @@
import type { UniqueID } from "@repo/rdx-ddd";
import { AggregateRoot } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { z } from "zod/v4";
import { SoftwareInstallationRevision } from "./software-installation-revision.entity";
import {
SOFTWARE_STATE_TRANSITIONS,
SoftwareInstallationError,
} from "./software-installation-values";
import type { SoftwareInstallationValues } from "./value-objects/software-installation-values.vo";
type Props = Readonly<{
companyId: UniqueID;
values: SoftwareInstallationValues;
history: readonly SoftwareInstallationRevision[];
}>;
export class SoftwareInstallation extends AggregateRoot<Props> {
private constructor(props: Props, id?: UniqueID) {
super(Object.freeze({ ...props, history: Object.freeze([...props.history]) }), id);
}
static create(
companyId: UniqueID,
values: SoftwareInstallationValues,
actorId: UniqueID,
recordedAt: string
): Result<SoftwareInstallation, SoftwareInstallationError> {
if (
!(
z.iso.datetime().safeParse(recordedAt).success &&
["pending", "configured"].includes(values.state)
)
) {
return Result.fail(new SoftwareInstallationError("INVALID_SOFTWARE_TRANSITION"));
}
return Result.ok(
new SoftwareInstallation({
companyId,
values,
history: [SoftwareInstallationRevision.create({ values, actorId, recordedAt })],
})
);
}
static rehydrate(props: Props, id: UniqueID) {
return new SoftwareInstallation(props, id);
}
get companyId() {
return this.props.companyId;
}
get values() {
return this.props.values;
}
get history() {
return this.props.history;
}
update(
values: SoftwareInstallationValues,
actorId: UniqueID,
recordedAt: string
): Result<SoftwareInstallation, SoftwareInstallationError> {
if (this.values.equals(values)) {
return Result.ok(this);
}
if (!this.values.sameTarget(values)) {
return Result.fail(new SoftwareInstallationError("SOFTWARE_TARGET_IMMUTABLE"));
}
if (
!SOFTWARE_STATE_TRANSITIONS[this.values.state].some((state) => state === values.state) ||
(this.values.closed && !this.values.equals(values))
) {
return Result.fail(new SoftwareInstallationError("INVALID_SOFTWARE_TRANSITION"));
}
if (
!z.iso.datetime().safeParse(recordedAt).success ||
recordedAt < (this.history.at(-1)?.recordedAt ?? "")
) {
return Result.fail(new SoftwareInstallationError("INVALID_REQUEST"));
}
return Result.ok(
new SoftwareInstallation(
{
...this.props,
values,
history: [
...this.history,
SoftwareInstallationRevision.create({ values, actorId, recordedAt }),
],
},
this.id
)
);
}
}

View File

@ -0,0 +1,133 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { UniqueID } from "@repo/rdx-ddd";
import type { SoftwareContext } from "../application/software-installation.repository";
import { mapSoftwareInput } from "../application/software-installation-input.mapper";
import { buildSoftwareResponse } from "../application/software-installation-response.builder";
import { SoftwareInstallationPersistenceMapper } from "../infrastructure/persistence/software-installation-persistence.mapper";
import { SoftwareInstallation } from "./software-installation.aggregate";
import type { SoftwareInstallationInput } from "./software-installation-values";
import { SoftwareInstallationInputSchema } from "./software-installation-values";
import { SoftwareInstallationValues } from "./value-objects/software-installation-values.vo";
const company = UniqueID.generateNewID();
const asset = UniqueID.generateNewID();
const resource = UniqueID.generateNewID();
const actor = UniqueID.generateNewID();
const date = "2026-10-12T10:00:00.000Z";
const input: SoftwareInstallationInput = {
assetId: asset.toPrimitive(),
resourceId: resource.toPrimitive(),
version: "24H2",
state: "pending",
notes: null,
};
const context: SoftwareContext = {
configurations: [],
assets: [{ id: asset, available: true }],
resources: [{ id: resource, active: true, usage: "os" }],
actors: new Map([[actor.toPrimitive(), "David"]]),
};
function values(data = input) {
const result = SoftwareInstallationValues.create(data);
assert.ok(result.isSuccess);
return result.data;
}
function installation() {
const result = SoftwareInstallation.create(company, values(), actor, date);
assert.ok(result.isSuccess);
return result.data;
}
test("instalaciones: pertenecen al equipo, admiten SO/aplicaciones y rechazan recursos ajenos y destinos variables", () => {
assert.ok(mapSoftwareInput(input, context).isSuccess);
assert.ok(
mapSoftwareInput(input, {
...context,
resources: [{ id: resource, active: true, usage: "application" }],
}).isSuccess
);
assert.ok(!mapSoftwareInput(input, { ...context, assets: [] }).isSuccess);
assert.ok(
!mapSoftwareInput(input, {
...context,
resources: [{ id: resource, active: true, usage: "certificate" }],
}).isSuccess
);
assert.equal(
SoftwareInstallationInputSchema.safeParse({ ...input, profileId: actor.toPrimitive() }).success,
false
);
const previous = installation();
assert.ok(!mapSoftwareInput(input, { ...context, configurations: [previous] }).isSuccess);
assert.ok(
mapSoftwareInput(input, { ...context, configurations: [previous] }, previous).isSuccess
);
assert.ok(
!previous.update(values({ ...input, assetId: actor.toPrimitive() }), actor, date).isSuccess
);
});
test("instalaciones: versiones, idempotencia, retirada, revisión y persistencia de historial", () => {
const previous = installation();
assert.ok(previous.id.toPrimitive().charAt(14) === "7");
const same = previous.update(values(), actor, date);
assert.ok(same.isSuccess);
assert.equal(same.data, previous);
const configured = previous.update(
values({ ...input, state: "configured", version: "25H2" }),
actor,
date
);
assert.ok(configured.isSuccess);
const unavailable = { ...context, assets: [{ id: asset, available: false }] };
assert.ok(buildSoftwareResponse(configured.data, unavailable).needsReview);
assert.ok(
mapSoftwareInput(
{ ...configured.data.values.toPrimitive(), notes: "Revisar equipo" },
unavailable,
configured.data
).isSuccess
);
assert.ok(!mapSoftwareInput(input, unavailable).isSuccess);
assert.ok(!configured.data.update(values({ ...input, state: "removed" }), actor, date).isSuccess);
const retiring = configured.data.update(
values({ ...input, state: "removal_pending" }),
actor,
date
);
assert.ok(retiring.isSuccess);
const removed = retiring.data.update(values({ ...input, state: "removed" }), actor, date);
assert.ok(removed.isSuccess);
assert.ok(!removed.data.update(values(), actor, date).isSuccess);
assert.ok(mapSoftwareInput(input, { ...context, configurations: [removed.data] }).isSuccess);
const mapper = new SoftwareInstallationPersistenceMapper();
const rows = mapper.toPersistence(removed.data);
const restored = mapper.toDomain(rows.root, rows.revisions);
assert.equal(restored.history.length, 4);
assert.ok(restored.values.equals(removed.data.values));
assert.throws(() => mapper.toDomain({ ...rows.root, currentKey: "incorrect" }, rows.revisions));
assert.throws(() =>
mapper.toDomain(
rows.root,
rows.revisions.map((row) => ({ ...row, position: 9 }))
)
);
});

View File

@ -0,0 +1,74 @@
import { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { SoftwareInstallationInput } from "../software-installation-values";
import {
SoftwareInstallationError,
SoftwareInstallationInputSchema,
} from "../software-installation-values";
export class SoftwareInstallationValues {
private constructor(
public readonly assetId: UniqueID,
public readonly resourceId: UniqueID,
public readonly version: Maybe<string>,
public readonly state: SoftwareInstallationInput["state"],
public readonly notes: Maybe<string>
) {
Object.freeze(this);
}
static create(
input: SoftwareInstallationInput
): Result<SoftwareInstallationValues, SoftwareInstallationError> {
const parsed = SoftwareInstallationInputSchema.safeParse(input);
if (!parsed.success) {
return Result.fail(new SoftwareInstallationError("INVALID_REQUEST"));
}
const data = parsed.data;
const asset = UniqueID.create(data.assetId);
const resource = UniqueID.create(data.resourceId);
if (!(asset.isSuccess && resource.isSuccess)) {
return Result.fail(new SoftwareInstallationError("INVALID_REQUEST"));
}
return Result.ok(
new SoftwareInstallationValues(
asset.data,
resource.data,
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: SoftwareInstallationValues) {
return this.assetId.equals(other.assetId) && this.resourceId.equals(other.resourceId);
}
equals(other: SoftwareInstallationValues) {
return (
this.sameTarget(other) &&
this.state === other.state &&
this.version.getOrUndefined() === other.version.getOrUndefined() &&
this.notes.getOrUndefined() === other.notes.getOrUndefined()
);
}
toPrimitive(): SoftwareInstallationInput {
return {
assetId: this.assetId.toPrimitive(),
resourceId: this.resourceId.toPrimitive(),
version: this.version.getOrUndefined() ?? null,
state: this.state,
notes: this.notes.getOrUndefined() ?? null,
};
}
}

View File

@ -0,0 +1,117 @@
import type { Application, RequestHandler, Response } from "express";
import { Router } from "express";
import { z } from "zod/v4";
import {
SaveSoftwareInstallationRequestSchema,
SoftwareItemParamsSchema,
SoftwareParamsSchema,
} from "../../../common/software-installations";
import type { SoftwareInstallationUseCases } from "../../application/software-installation.use-cases";
import { buildSoftwareResponse } from "../../application/software-installation-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 registerSoftwareInstallationRoutes(
app: Application,
useCases: SoftwareInstallationUseCases,
authenticate: RequestHandler
) {
const router = Router();
router.get("/:companyId", (request, response, next) => {
const params = SoftwareParamsSchema.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) =>
buildSoftwareResponse(item, result.data)
),
});
})
.catch(next);
});
for (const path of ["/:companyId", "/:companyId/:id/update"]) {
router.post(path, (request, response, next) => {
const params = (
path.endsWith("update") ? SoftwareItemParamsSchema : SoftwareParamsSchema
).safeParse(request.params);
const body = SaveSoftwareInstallationRequestSchema.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: buildSoftwareResponse(result.data.configuration, result.data.context),
});
})
.catch(next);
});
}
router.post("/:companyId/:id/delete", (request, response, next) => {
const params = SoftwareItemParamsSchema.safeParse(request.params);
if (
!(
params.success &&
z.strictObject({}).safeParse(request.body).success &&
z.strictObject({}).safeParse(request.query).success
)
) {
fail(response, "INVALID_REQUEST");
return;
}
useCases
.remove(params.data.companyId, params.data.id)
.then((result) => {
if (!result.isSuccess) {
fail(response, result.error.code);
return;
}
response.json({ deleted: true });
})
.catch(next);
});
app.use("/api/software-installations", authenticate, router);
}

View File

@ -0,0 +1,141 @@
import type { IResourceInventoryService } from "@metis/resources/services";
import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { Sequelize, Transaction } from "sequelize";
import { QueryTypes } from "sequelize";
import type {
ISoftwareInstallationRepository,
SoftwareContext,
} from "../../application/software-installation.repository";
import { softwareReferences } from "../../application/software-installation-policy";
import type { IWorkProfileInventoryService } from "../../application/work-profile.repository";
import { SoftwareInstallationError } from "../../domain/software-installation-values";
import type { SoftwareRevisionRow, SoftwareRow } from "./software-installation-persistence.mapper";
import { SoftwareInstallationPersistenceMapper } from "./software-installation-persistence.mapper";
export class SequelizeSoftwareInstallationRepository implements ISoftwareInstallationRepository {
private readonly mapper = new SoftwareInstallationPersistenceMapper();
constructor(
private readonly database: Sequelize,
private readonly inventory: IWorkProfileInventoryService,
private readonly resources: IResourceInventoryService
) {}
private async load(
company: UniqueID,
transaction: Transaction,
write: boolean
): Promise<Result<SoftwareContext, SoftwareInstallationError>> {
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 SoftwareInstallationError("COMPANY_NOT_FOUND"));
}
const rows = await this.database.query<SoftwareRow>(
"SELECT * FROM software_installations WHERE companyId=:companyId",
{ ...options, type: QueryTypes.SELECT }
);
const revisions = await this.database.query<SoftwareRevisionRow>(
"SELECT r.* FROM software_installation_revisions r JOIN software_installations m ON m.id=r.configurationId WHERE m.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: SoftwareContext = {
configurations: rows.map((row) =>
this.mapper.toDomain(
row,
revisions.filter((r) => r.configurationId === row.id)
)
),
assets: await this.inventory.list(company),
resources: await this.resources.list(company),
actors: new Map(actors.map((actor) => [actor.id, actor.name])),
};
if (context.configurations.some((mapping) => !softwareReferences(mapping.values, context))) {
throw new Error("Ámbito de instalación persistido inconsistente.");
}
return Result.ok(context);
}
get(company: UniqueID) {
return this.database.transaction((t) => this.load(company, t, false));
}
remove(company: UniqueID, id: UniqueID): Promise<Result<void, SoftwareInstallationError>> {
return this.database.transaction(async (transaction) => {
const replacements = { companyId: company.toPrimitive(), id: id.toPrimitive() };
await this.database.query("SELECT id FROM companies WHERE id=:companyId FOR UPDATE", {
replacements,
transaction,
type: QueryTypes.SELECT,
});
const rows = await this.database.query<{ id: string }>(
"SELECT id FROM software_installations WHERE companyId=:companyId AND id=:id FOR UPDATE",
{ replacements, transaction, type: QueryTypes.SELECT }
);
if (!rows.length) {
return Result.fail(new SoftwareInstallationError("SOFTWARE_CONFIGURATION_NOT_FOUND"));
}
await this.database.query(
"DELETE FROM software_installation_revisions WHERE configurationId=:id",
{ replacements, transaction }
);
await this.database.query(
"DELETE FROM software_installations WHERE companyId=:companyId AND id=:id",
{ replacements, transaction }
);
return Result.ok(undefined);
});
}
save(company: UniqueID, action: Parameters<ISoftwareInstallationRepository["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 software_installations (id,companyId,assetId,resourceId,version,state,notes,currentKey) VALUES (:id,:companyId,:assetId,:resourceId,:version,:state,:notes,:currentKey) ON DUPLICATE KEY UPDATE version=VALUES(version),state=VALUES(state),notes=VALUES(notes),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 software_installation_revisions (id,configurationId,position,actorId,recordedAt,assetId,resourceId,version,state,notes) VALUES (:id,:configurationId,:position,:actorId,:recordedAt,:assetId,:resourceId,:version,:state,:notes)",
{ replacements: revision, transaction }
);
}
}
return Result.ok({ configuration: candidate.data, context: loaded.data });
});
}
}

View File

@ -0,0 +1,49 @@
import type { Sequelize } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { softwareSnapshot } from "./software-installation-snapshot";
export async function migrateSoftwareInstallations(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 === "software_installations")) {
await q.createTable("software_installations", {
id: { type: DataTypes.UUID, primaryKey: true },
companyId: fk("companies"),
...softwareSnapshot.columns,
currentKey: { type: DataTypes.STRING(80), allowNull: true, unique: true },
});
}
if (!tables.some((t) => t.name === "software_installation_revisions")) {
await q.createTable("software_installation_revisions", {
id: { type: DataTypes.UUID, primaryKey: true },
configurationId: fk("software_installations"),
position: { type: DataTypes.INTEGER, allowNull: false },
...softwareSnapshot.columns,
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='software_installation_revisions'",
{ type: QueryTypes.SELECT }
);
if (!indexes.some((item) => item.name === "software_installation_position")) {
await q.addIndex("software_installation_revisions", ["configurationId", "position"], {
unique: true,
name: "software_installation_position",
});
}
}

View File

@ -0,0 +1,130 @@
import { z } from "zod/v4";
import { SoftwareInstallation } from "../../domain/software-installation.aggregate";
import { SoftwareInstallationRevision } from "../../domain/software-installation-revision.entity";
import {
SOFTWARE_STATE_TRANSITIONS,
SoftwareInstallationInputSchema,
} from "../../domain/software-installation-values";
import { SoftwareInstallationValues } from "../../domain/value-objects/software-installation-values.vo";
import { snapshotValues } from "./scalar-snapshot-reader";
import { softwareSnapshot } from "./software-installation-snapshot";
import { profileIdentity } from "./work-profile-persistence.mapper";
export type SoftwareRow = {
id: string;
companyId: string;
assetId: string;
resourceId: string;
currentKey: string | null;
};
export type SoftwareRevisionRow = {
id: string;
configurationId: string;
position: number;
actorId: string;
recordedAt: string;
};
function mapped(raw: unknown) {
const parsed = SoftwareInstallationValues.create(SoftwareInstallationInputSchema.parse(raw));
if (!parsed.isSuccess) {
throw new Error("Valores de instalación persistidos inconsistentes.");
}
return parsed.data;
}
export class SoftwareInstallationPersistenceMapper {
toDomain(row: SoftwareRow, revisions: readonly SoftwareRevisionRow[]) {
const values = mapped(snapshotValues(softwareSnapshot, row));
const ordered = [...revisions].sort((a, b) => a.position - b.position);
const history = ordered.map((revision, index) => {
const value = mapped(snapshotValues(softwareSnapshot, revision));
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 instalación persistido inconsistente.");
}
return SoftwareInstallationRevision.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 instalación persistido inconsistente.");
}
for (const [index, revision] of history.entries()) {
const previous = history[index - 1]?.values;
if (
previous &&
(!SOFTWARE_STATE_TRANSITIONS[previous.state].some(
(state) => state === revision.values.state
) ||
(previous.closed && !previous.equals(revision.values)))
) {
throw new Error("Estados de instalación históricos inconsistentes.");
}
}
const keys = softwareKeys(values);
if (
!history.at(-1)?.values.equals(values) ||
row.assetId !== values.assetId.toPrimitive() ||
row.resourceId !== values.resourceId.toPrimitive() ||
row.currentKey !== keys.currentKey
) {
throw new Error("Referencias de instalación persistidas inconsistentes.");
}
return SoftwareInstallation.rehydrate(
{ companyId: profileIdentity(row.companyId), values, history },
profileIdentity(row.id)
);
}
toPersistence(item: SoftwareInstallation) {
const values = item.values.toPrimitive();
return {
root: {
id: item.id.toPrimitive(),
companyId: item.companyId.toPrimitive(),
...values,
...softwareKeys(item.values),
},
revisions: item.history.map((revision, position) => ({
id: revision.id.toPrimitive(),
configurationId: item.id.toPrimitive(),
position,
actorId: revision.actorId.toPrimitive(),
recordedAt: revision.recordedAt,
...revision.values.toPrimitive(),
})),
};
}
}
function softwareKeys(values: SoftwareInstallationValues) {
return {
currentKey: values.closed
? null
: `${values.assetId.toPrimitive()}:${values.resourceId.toPrimitive()}`,
};
}

View File

@ -0,0 +1,27 @@
import { DataTypes } from "sequelize";
import { SoftwareInstallationInputSchema } from "../../domain/software-installation-values";
import type { SnapshotDefinition } from "./scalar-snapshot-reader";
export const softwareSnapshot = {
schema: SoftwareInstallationInputSchema,
columns: {
assetId: {
type: DataTypes.UUID,
allowNull: false,
references: { model: "assets", key: "id" },
onDelete: "RESTRICT",
},
resourceId: {
type: DataTypes.UUID,
allowNull: false,
references: { model: "resources", key: "id" },
onDelete: "RESTRICT",
},
version: { type: DataTypes.STRING(100), allowNull: true },
state: { type: DataTypes.STRING(20), allowNull: false },
notes: { type: DataTypes.TEXT, allowNull: true },
},
booleans: [],
} satisfies SnapshotDefinition<ReturnType<typeof SoftwareInstallationInputSchema.parse>>;

View File

@ -19,32 +19,34 @@ import { InterventionTemplateUseCases } from "./application/intervention-templat
import type { IMailInventoryService } from "./application/mail-configuration.repository";
import { MailConfigurationUseCases } from "./application/mail-configuration.use-cases";
import { MailboxUseCases } from "./application/mailbox.use-cases";
import { PersonPhotoUseCases } from "./application/person-photo.use-cases";
import { PrinterConfigurationUseCases } from "./application/printer-configuration.use-cases";
import type { IProcessAssetInventoryService } from "./application/process.repository";
import { ProcessUseCases } from "./application/process.use-cases";
import { RemoteConnectionUseCases } from "./application/remote-connection.use-cases";
import { SoftwareInstallationUseCases } from "./application/software-installation.use-cases";
import { TeleworkAuthorizationUseCases } from "./application/telework-authorization.use-cases";
import type { ITicketInventoryService } from "./application/ticket.repository";
import { TicketUseCases } from "./application/ticket.use-cases";
import type { IWorkProfileInventoryService } from "./application/work-profile.repository";
import { WorkProfileUseCases } from "./application/work-profile.use-cases";
import { CompanyLogoProcessor } from "./infrastructure/company-logo-processor";
import { PersonPhotoProcessor } from "./infrastructure/person-photo-processor";
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 { registerCompanyLogoRoutes } from "./infrastructure/http/company-logo.routes";
import { registerPersonPhotoRoutes } from "./infrastructure/http/person-photo.routes";
import { registerDashboardRoutes } from "./infrastructure/http/dashboard.routes";
import { registerFolderMappingRoutes } from "./infrastructure/http/folder-mapping.routes";
import { registerInterventionRoutes } from "./infrastructure/http/intervention.routes";
import { registerInterventionTemplateRoutes } from "./infrastructure/http/intervention-template.routes";
import { registerMailRoutes } from "./infrastructure/http/mail-configuration.routes";
import { registerMailboxRoutes } from "./infrastructure/http/mailbox.routes";
import { registerPersonPhotoRoutes } from "./infrastructure/http/person-photo.routes";
import { registerPrinterConfigurationRoutes } from "./infrastructure/http/printer-configuration.routes";
import { registerProcessRoutes } from "./infrastructure/http/process.routes";
import { registerRemoteConnectionRoutes } from "./infrastructure/http/remote-connection.routes";
import { registerSoftwareInstallationRoutes } from "./infrastructure/http/software-installation.routes";
import { registerTeleworkAuthorizationRoutes } from "./infrastructure/http/telework-authorization.routes";
import { registerTicketRoutes } from "./infrastructure/http/ticket.routes";
import { registerWorkProfileRoutes } from "./infrastructure/http/work-profile.routes";
@ -66,6 +68,7 @@ import { migrateMailboxes } from "./infrastructure/persistence/mailbox-migration
import { migrateCompanies } from "./infrastructure/persistence/migrate-companies";
import { migratePeople } from "./infrastructure/persistence/migrate-people";
import { initializePeopleModels } from "./infrastructure/persistence/people.model";
import { migratePersonPhotos } from "./infrastructure/persistence/person-photo-migration";
import { migratePrinterConfigurations } from "./infrastructure/persistence/printer-configuration-migration";
import { migrateProcesses } from "./infrastructure/persistence/process-migration";
import { migrateRemoteConnections } from "./infrastructure/persistence/remote-connection-migration";
@ -73,24 +76,25 @@ import { SequelizeApplicationConfigurationRepository } from "./infrastructure/pe
import { SequelizeCertificateConfigurationRepository } from "./infrastructure/persistence/sequelize-certificate-configuration.repository";
import { SequelizeCompanyRepository } from "./infrastructure/persistence/sequelize-company.repository";
import { SequelizeCompanyLogoRepository } from "./infrastructure/persistence/sequelize-company-logo.repository";
import { SequelizePersonPhotoRepository } from "./infrastructure/persistence/sequelize-person-photo.repository";
import { migratePersonPhotos } from "./infrastructure/persistence/person-photo-migration";
import { PersonPhotoUseCases } from "./application/person-photo.use-cases";
import { SequelizeDashboardRepository } from "./infrastructure/persistence/sequelize-dashboard.repository";
import { SequelizeFolderMappingRepository } from "./infrastructure/persistence/sequelize-folder-mapping.repository";
import { SequelizeInterventionRepository } from "./infrastructure/persistence/sequelize-intervention.repository";
import { SequelizeInterventionTemplateRepository } from "./infrastructure/persistence/sequelize-intervention-template.repository";
import { SequelizeMailConfigurationRepository } from "./infrastructure/persistence/sequelize-mail-configuration.repository";
import { SequelizeMailboxRepository } from "./infrastructure/persistence/sequelize-mailbox.repository";
import { SequelizePersonPhotoRepository } from "./infrastructure/persistence/sequelize-person-photo.repository";
import { SequelizePrinterConfigurationRepository } from "./infrastructure/persistence/sequelize-printer-configuration.repository";
import { SequelizeProcessRepository } from "./infrastructure/persistence/sequelize-process.repository";
import { SequelizeRemoteConnectionRepository } from "./infrastructure/persistence/sequelize-remote-connection.repository";
import { SequelizeSoftwareInstallationRepository } from "./infrastructure/persistence/sequelize-software-installation.repository";
import { SequelizeTeleworkAuthorizationRepository } from "./infrastructure/persistence/sequelize-telework-authorization.repository";
import { SequelizeTicketRepository } from "./infrastructure/persistence/sequelize-ticket.repository";
import { SequelizeWorkProfileRepository } from "./infrastructure/persistence/sequelize-work-profile.repository";
import { migrateSoftwareInstallations } from "./infrastructure/persistence/software-installation-migration";
import { migrateTeleworkAuthorizations } from "./infrastructure/persistence/telework-authorization-migration";
import { migrateTickets } from "./infrastructure/persistence/ticket-migration";
import { migrateWorkProfiles } from "./infrastructure/persistence/work-profile-migration";
import { PersonPhotoProcessor } from "./infrastructure/person-photo-processor";
export async function createCompanyModule(
database: Sequelize,
@ -172,6 +176,11 @@ export async function createCompanyModule(
await migrateFolderMappings(database);
await migratePrinterConfigurations(database);
await migrateApplicationConfigurations(database);
await migrateSoftwareInstallations(database);
const software = new SoftwareInstallationUseCases(
new SequelizeSoftwareInstallationRepository(database, inventory, resources)
);
await migrateCertificateConfigurations(database);
const certificates = new CertificateConfigurationUseCases(
new SequelizeCertificateConfigurationRepository(database, inventory, resources)
@ -199,6 +208,7 @@ export async function createCompanyModule(
registerFolderMappingRoutes(app, folders, authenticate);
registerPrinterConfigurationRoutes(app, printers, authenticate);
registerApplicationConfigurationRoutes(app, applications, authenticate);
registerSoftwareInstallationRoutes(app, software, authenticate);
registerCertificateConfigurationRoutes(app, certificates, authenticate);
},
};

View File

@ -1,48 +1,28 @@
export * from "./application-configurations";
export * from "./certificate-configurations";
export {
COMPANY_LOGO_LIMITS,
COMPANY_LOGO_TYPES,
CompanyLogoResponseSchema,
CompanyLogoVersionSchema,
} from "./company-logo";
export * from "./dashboard";
export * from "./dto/request/access.request.dto";
export * from "./dto/request/company.request.dto";
export * from "./dto/request/people.request.dto";
export * from "./dto/response/company.response.dto";
export * from "./dto/shared/access.dto";
export * from "./dto/shared/company.dto";
export * from "./dto/shared/people.dto";
export * from "./folder-mappings";
export * from "./intervention-templates";
export * from "./interventions";
export * from "./mail-configurations";
export * from "./mailboxes";
export * from "./printer-configurations";
export * from "./processes";
export * from "./remote-connections";
export * from "./software-installations";
export * from "./telework-authorizations";
export * from "./tickets";
export * from "./work-profiles";

View File

@ -938,7 +938,36 @@
"usedWorkstations": "Workstations they use",
"noResponsibleWorkstations": "This person is not responsible for any active workstation.",
"noUsedWorkstations": "This person has no active profiles on the main computers of active workstations.",
"workstationProfilesLoadError": "Profiles could not be loaded. Workstation usage cannot be determined."
"workstationProfilesLoadError": "Profiles could not be loaded. Workstation usage cannot be determined.",
"software": {
"title": "Computer software",
"new": "Add installation",
"edit": "Edit installation",
"resource": "Software",
"type": "Type",
"os": "Operating system",
"application": "Application",
"description": "Record the current operating system and software installed on this computer.",
"help": "The installation belongs to the computer. Profile applications describe each person’s requirements and availability. The factory operating system remains on the asset. Programs are not installed and licenses are not consumed automatically.",
"noComputer": "Designate the workstation main computer first.",
"empty": "No installations recorded.",
"review": "Needs review",
"loadError": "Unable to load installations and resources.",
"errors": {
"INVALID_REQUEST": "Check the installation details.",
"COMPANY_NOT_FOUND": "Company not found.",
"SOFTWARE_CONFIGURATION_NOT_FOUND": "Installation not found.",
"SOFTWARE_REFERENCE_NOT_FOUND": "The computer or resource does not belong to this company, or the resource is not software.",
"SOFTWARE_REFERENCE_UNAVAILABLE": "The computer is retired or the resource is inactive.",
"SOFTWARE_ALREADY_OPEN": "An open installation of this software already exists on this computer.",
"SOFTWARE_TARGET_IMMUTABLE": "The computer and resource cannot change.",
"INVALID_SOFTWARE_TRANSITION": "This state transition is not allowed.",
"INTERNAL_ERROR": "Unable to save the installation."
},
"examples": "Examples: Windows 11 Pro 24H2, Thunderbird 140 or Microsoft 365 Apps.",
"showClosed": "Include removed and never configured installations",
"newOs": "New operating system"
}
},
"access": {
"helpTitle": "What is it and what is it for?",

View File

@ -938,7 +938,36 @@
"usedWorkstations": "Puestos que utiliza",
"noResponsibleWorkstations": "No es responsable de ningún puesto activo.",
"noUsedWorkstations": "No tiene perfiles vigentes en los ordenadores principales de puestos activos.",
"workstationProfilesLoadError": "No se pudieron consultar los perfiles. No se puede determinar qué puestos utiliza."
"workstationProfilesLoadError": "No se pudieron consultar los perfiles. No se puede determinar qué puestos utiliza.",
"software": {
"title": "Software del ordenador",
"new": "Añadir instalación",
"edit": "Editar instalación",
"resource": "Software",
"type": "Tipo",
"os": "Sistema operativo",
"application": "Aplicación",
"description": "Registra el sistema operativo actual y el software instalado en este ordenador.",
"help": "La instalación pertenece al ordenador. Las aplicaciones de cada perfil documentan las necesidades y disponibilidad de cada persona. El sistema operativo de fábrica permanece en el activo. No se instalan programas ni se consumen licencias automáticamente.",
"noComputer": "Designa primero el ordenador principal del puesto.",
"empty": "No hay instalaciones registradas.",
"review": "Requiere revisión",
"loadError": "No se pudieron consultar las instalaciones y los recursos.",
"errors": {
"INVALID_REQUEST": "Revisa los datos de la instalación.",
"COMPANY_NOT_FOUND": "La compañía no existe.",
"SOFTWARE_CONFIGURATION_NOT_FOUND": "La instalación no existe.",
"SOFTWARE_REFERENCE_NOT_FOUND": "El ordenador o el recurso no pertenecen a esta compañía, o el recurso no es software.",
"SOFTWARE_REFERENCE_UNAVAILABLE": "El ordenador está retirado o el recurso está inactivo.",
"SOFTWARE_ALREADY_OPEN": "Ya hay una instalación abierta de este software en el ordenador.",
"SOFTWARE_TARGET_IMMUTABLE": "El ordenador y el recurso no pueden cambiar.",
"INVALID_SOFTWARE_TRANSITION": "Este cambio de estado no está permitido.",
"INTERNAL_ERROR": "No se pudo guardar la instalación."
},
"examples": "Ejemplos: Windows 11 Pro 24H2, Thunderbird 140 o Microsoft 365 Apps.",
"showClosed": "Mostrar también las retiradas y nunca configuradas",
"newOs": "Nuevo sistema operativo"
}
},
"access": {
"helpTitle": "¿Qué es y para qué sirve?",

View File

@ -0,0 +1,45 @@
import { z } from "zod/v4";
import { SoftwareInstallationInputSchema } from "../api/domain/software-installation-values";
export type { SoftwareInstallationInput } from "../api/domain/software-installation-values";
export {
SOFTWARE_STATE_TRANSITIONS,
SoftwareInstallationInputSchema,
} from "../api/domain/software-installation-values";
export const SoftwareParamsSchema = z.strictObject({ companyId: z.uuid() });
export const SoftwareItemParamsSchema = SoftwareParamsSchema.extend({ id: z.uuid() });
export const SaveSoftwareInstallationRequestSchema = z.strictObject({
values: SoftwareInstallationInputSchema,
});
export const SoftwareInstallationSchema = z.strictObject({
id: z.uuid(),
companyId: z.uuid(),
values: SoftwareInstallationInputSchema,
needsReview: z.boolean(),
history: z
.array(
z.strictObject({
id: z.uuid(),
actorId: z.uuid(),
actorName: z.string().min(1),
recordedAt: z.iso.datetime(),
values: SoftwareInstallationInputSchema,
})
)
.min(1),
});
export const SoftwareInstallationsResponseSchema = z.strictObject({
configurations: z.array(SoftwareInstallationSchema),
});
export const SoftwareInstallationResponseSchema = z.strictObject({
configuration: SoftwareInstallationSchema,
});
export type SoftwareInstallationDTO = z.infer<typeof SoftwareInstallationSchema>;

View File

@ -0,0 +1,49 @@
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
import { useTranslation } from "react-i18next";
import type { SoftwareInstallationDTO } from "../../../../common";
export function SoftwareHistoryDialog({
configuration,
onClose,
}: {
onClose: () => void;
configuration: SoftwareInstallationDTO;
}) {
const { t, i18n } = useTranslation("companies");
return (
<FormDialog
cancelLabel={t("close")}
onOpenChange={(open) => {
if (!open) {
onClose();
}
}}
open
title={t("mail.history")}
wide
>
<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.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>
</FormDialog>
);
}

View File

@ -0,0 +1,248 @@
import { BrowserLink, useListQuery } from "@metis/core/client";
import {
catalogApi,
catalogKeys,
inventoryApi as resourceApi,
inventoryKeys as resourceKeys,
} from "@metis/resources/client";
import {
EntityAction,
EntityListHeader,
EntityRowActions,
EntityStatus,
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
import { Button } from "@repo/shadcn-ui/components/button";
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
import { useId, useState } from "react";
import { useTranslation } from "react-i18next";
import { DeleteRecordDialog } from "../../../shared/ui/delete-record-dialog";
import { SoftwareHistoryDialog } from "../../history/ui/software-history-dialog";
import { softwareApi, softwareKeys } from "../../shared/api/software-api";
import { SoftwareInstallationForm } from "../../shared/ui/software-installation-form";
export function SoftwareInstallationsPanel({
companyId,
assetId,
canCreate = true,
}: {
companyId: string;
assetId: string | null | undefined;
canCreate?: boolean;
}) {
const { t, i18n } = useTranslation("companies");
const id = useId();
const [editor, setEditor] = useState<string>();
const [view, setView] = useState<string>();
const [deleting, setDeleting] = useState<string>();
const [search, setSearch] = useState("");
const [closed, setClosed] = useState(false);
const query = useListQuery({
queryKey: softwareKeys.company(companyId),
queryFn: () => softwareApi.get(companyId),
});
const resources = useListQuery({
queryKey: resourceKeys.company(companyId),
queryFn: () => resourceApi.get(companyId),
});
const catalog = useListQuery({
queryKey: catalogKeys.scope(companyId),
queryFn: () => catalogApi.get(companyId),
});
const ready = query.isSuccess && resources.isSuccess && catalog.isSuccess;
const resource = (resourceId: string) =>
resources.data?.resources.find((item) => item.id === resourceId);
const usage = (resourceId: string) => {
const item = resource(resourceId);
const template = catalog.data?.items.find(
(template) => template.id === item?.values.templateId
);
if (template?.builtinKey === "template:os") {
return "os";
}
if (template?.builtinKey === "template:application") {
return "application";
}
return template?.values.kind === "template" ? template.values.usage : undefined;
};
const name = (resourceId: string) => resource(resourceId)?.values.name ?? "—";
const items = (query.data?.configurations ?? []).filter(
(item) => item.values.assetId === assetId
);
const edited = items.find((item) => item.id === editor);
const viewed = items.find((item) => item.id === view);
const visible = items.filter(
(item) =>
(closed || !["removed", "never_configured"].includes(item.values.state)) &&
`${name(item.values.resourceId)} ${item.values.version ?? ""}`
.toLocaleLowerCase(i18n.language)
.includes(search.toLocaleLowerCase(i18n.language))
);
const options = (resources.data?.resources ?? [])
.filter(
(item) =>
item.id === edited?.values.resourceId ||
(item.values.active && ["os", "application"].includes(usage(item.id) ?? ""))
)
.map((item) => ({
value: item.id,
label: `${item.values.name} · ${t(`software.${usage(item.id) === "os" ? "os" : "application"}`)}`,
}));
return (
<section aria-label={t("software.title")} className="space-y-4">
<EntityListHeader
actions={
<Button
disabled={!(assetId && ready && canCreate)}
onClick={() => setEditor("new")}
size="sm"
variant="outline"
>
{t("software.new")}
</Button>
}
description={t("software.description")}
locale={i18n.language}
title={t("software.title")}
/>
{!assetId && <p className="text-sm text-muted-foreground">{t("software.noComputer")}</p>}
{(query.isPending || resources.isPending || catalog.isPending) && (
<p role="status">{t("loading")}</p>
)}
{(query.isError || resources.isError || catalog.isError) && (
<div className="space-y-2" role="alert">
<p>{t("software.loadError")}</p>
<Button
onClick={() => {
Promise.all([query.refetch(), resources.refetch(), catalog.refetch()]).catch(
() => undefined
);
}}
variant="outline"
>
{t("retry")}
</Button>
</div>
)}
{assetId && ready && (
<EntityTable
columns={[
{
id: "name",
label: t("software.resource"),
hideable: false,
value: (item) => name(item.values.resourceId),
render: (item) => (
<BrowserLink href={`/companies/${companyId}/resources/${item.values.resourceId}`}>
{name(item.values.resourceId)}
</BrowserLink>
),
},
{
id: "type",
label: t("software.type"),
value: (item) => usage(item.values.resourceId),
render: (item) =>
t(`software.${usage(item.values.resourceId) === "os" ? "os" : "application"}`),
},
{
id: "version",
label: t("applications.version"),
value: (item) => item.values.version,
render: (item) => item.values.version || "—",
},
{
id: "state",
label: t("status"),
value: (item) => item.values.state,
render: (item) => (
<div>
<EntityStatus active={item.values.state === "configured"}>
{t(`mail.${item.values.state}`)}
</EntityStatus>
{item.needsReview && (
<p className="text-xs text-muted-foreground">{t("software.review")}</p>
)}
</div>
),
},
{
id: "actions",
label: t("actions"),
hideable: false,
render: (item) => (
<EntityRowActions label={t("actions")}>
<EntityAction onClick={() => setView(item.id)}>{t("mail.history")}</EntityAction>
{!["removed", "never_configured"].includes(item.values.state) && (
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
)}
<EntityAction className="text-destructive" onClick={() => setDeleting(item.id)}>
{t("deleteRecord.title")}
</EntityAction>
</EntityRowActions>
),
},
]}
emptyMessage={t("software.empty")}
entityLabel={t("software.title")}
filters={
<label className="flex items-center gap-2 text-sm" htmlFor={`${id}-closed`}>
<Checkbox
checked={closed}
id={`${id}-closed`}
onCheckedChange={(value) => setClosed(value === true)}
/>
{t("software.showClosed")}
</label>
}
filtersApplied={closed}
items={visible}
onClearFilters={() => setClosed(false)}
onSearchChange={setSearch}
search={search}
searchPlaceholder={t("applications.search")}
title={t("software.title")}
/>
)}
{editor && assetId && (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
setEditor(undefined);
}
}}
open
title={t(edited ? "software.edit" : "software.new")}
>
<SoftwareInstallationForm
assetId={assetId}
companyId={companyId}
installation={edited}
onSaved={() => setEditor(undefined)}
options={options}
/>
</FormDialog>
)}
{viewed && (
<SoftwareHistoryDialog configuration={viewed} onClose={() => setView(undefined)} />
)}
<DeleteRecordDialog
companyId={companyId}
onClose={() => setDeleting(undefined)}
path={deleting ? `/api/software-installations/${companyId}/${deleting}/delete` : undefined}
queryKey={softwareKeys.company(companyId)}
/>
</section>
);
}

View File

@ -0,0 +1,46 @@
import { csrfFetch } from "@metis/core/client";
import { z } from "zod/v4";
import type { SoftwareInstallationInput } from "../../../../common/software-installations";
import {
SoftwareInstallationResponseSchema,
SoftwareInstallationsResponseSchema,
} from "../../../../common/software-installations";
export const softwareKeys = { company: (id: string) => ["software-installations", id] };
export class SoftwareApiError extends Error {
constructor(public readonly code: string) {
super(code);
}
}
async function request(companyId: string, values?: SoftwareInstallationInput, id?: string) {
const response = await csrfFetch(
`/api/software-installations/${companyId}${id ? `/${id}/update` : ""}`,
{
method: values ? "POST" : "GET",
credentials: "same-origin",
headers: values ? { "content-type": "application/json" } : {},
...(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 SoftwareApiError(error.success ? error.data.code : "INTERNAL_ERROR");
}
return body;
}
export const softwareApi = {
async get(companyId: string) {
return SoftwareInstallationsResponseSchema.parse(await request(companyId));
},
async save(companyId: string, values: SoftwareInstallationInput, id?: string) {
return SoftwareInstallationResponseSchema.parse(await request(companyId, values, id));
},
};

View File

@ -0,0 +1,113 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { catalogKeys, inventoryKeys } 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 { SoftwareInstallationSchema } from "../../../common/software-installations";
import { companiesEnglish, companiesSpanish } from "../../shared";
import { SoftwareInstallationsPanel } from "../list/ui/software-installations-panel";
import { softwareKeys } from "./api/software-api";
import { SoftwareInstallationForm } from "./ui/software-installation-form";
const id = (n: number) => `0199b200-0000-7000-8000-${String(n).padStart(12, "0")}`;
const companyId = id(1);
const assetId = id(2);
const values = { assetId, resourceId: id(3), version: "24H2", state: "configured", notes: null };
const item = SoftwareInstallationSchema.parse({
id: id(4),
companyId,
values,
needsReview: false,
history: [
{
id: id(5),
actorId: id(6),
actorName: "David",
recordedAt: "2026-10-12T10:00:00.000Z",
values,
},
],
});
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(softwareKeys.company(companyId), {
configurations: [
item,
{ ...item, id: id(10), values: { ...values, assetId: id(20), version: "OTHER-COMPUTER" } },
],
});
client.setQueryData(inventoryKeys.company(companyId), {
resources: [{ id: id(3), values: { name: "Windows 11 Pro", active: true, templateId: id(7) } }],
});
client.setQueryData(catalogKeys.scope(companyId), {
items: [{ id: id(7), builtinKey: "template:os", values: { kind: "template", usage: "os" } }],
});
try {
return renderToStaticMarkup(
<I18nextProvider i18n={i18n}>
<QueryClientProvider client={client}>{node}</QueryClientProvider>
</I18nextProvider>
);
} finally {
client.clear();
}
}
test("software del puesto es/en: SO actual, versión, enlaces y separación por ordenador sin formularios incrustados", async () => {
for (const language of ["es", "en"]) {
const html = await render(
language,
<SoftwareInstallationsPanel assetId={assetId} companyId={companyId} />
);
assert.ok(html.includes("Windows 11 Pro"));
assert.ok(html.includes("24H2"));
assert.ok(html.includes(language === "es" ? "Sistema operativo" : "Operating system"));
assert.ok(html.includes(`href="/companies/${companyId}/resources/${id(3)}"`));
assert.equal(html.includes("OTHER-COMPUTER"), false);
assert.equal(html.includes("<form"), false);
const empty = await render(
language,
<SoftwareInstallationsPanel assetId={null} companyId={companyId} />
);
assert.ok(empty.includes(language === "es" ? "Designa primero" : "Designate"));
assert.equal(empty.includes("Windows 11 Pro"), false);
}
});
test("formulario software: recurso fijo en edición, estados permitidos, ayuda y ausencia de persona/perfil", async () => {
const html = await render(
"es",
<SoftwareInstallationForm
assetId={assetId}
companyId={companyId}
installation={item}
onSaved={() => undefined}
options={[{ value: id(3), label: "Windows 11 Pro" }]}
/>
);
assert.ok(html.includes("La instalación pertenece al ordenador"));
assert.ok(html.includes("Guardar"));
assert.equal(html.includes("software.examples"), false);
assert.equal(html.includes('name="profileId"'), false);
assert.equal(html.includes('name="personId"'), false);
});

View File

@ -0,0 +1,170 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { ResourceEditor } from "@metis/resources/client";
import { ContextHelp } from "@repo/rdx-ui/components/context-help";
import { FormActions, FormBody, FormDialog } from "@repo/rdx-ui/components/form-dialog";
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
import { Button } from "@repo/shadcn-ui/components/button";
import { Input } from "@repo/shadcn-ui/components/input";
import { Label } from "@repo/shadcn-ui/components/label";
import { Textarea } from "@repo/shadcn-ui/components/textarea";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useId, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { SoftwareInstallationDTO, SoftwareInstallationInput } from "../../../../common";
import { SOFTWARE_STATE_TRANSITIONS, SoftwareInstallationInputSchema } from "../../../../common";
import { SoftwareApiError, softwareApi, softwareKeys } from "../api/software-api";
export function SoftwareInstallationForm({
companyId,
assetId,
installation,
options,
onSaved,
}: {
companyId: string;
assetId: string;
installation: SoftwareInstallationDTO | undefined;
options: readonly { value: string; label: string }[];
onSaved: () => void;
}) {
const { t } = useTranslation("companies");
const prefix = useId();
const [quick, setQuick] = useState<"os" | "application">();
const client = useQueryClient();
const form = useForm<SoftwareInstallationInput>({
resolver: zodResolver(SoftwareInstallationInputSchema),
defaultValues: installation?.values ?? {
assetId,
resourceId: "",
version: null,
state: "pending",
notes: null,
},
});
const states = installation
? SOFTWARE_STATE_TRANSITIONS[installation.values.state]
: (["pending", "configured"] as const);
const mutation = useMutation({
mutationFn: (values: SoftwareInstallationInput) =>
softwareApi.save(companyId, values, installation?.id),
onSuccess: async () => {
await client.invalidateQueries({ queryKey: softwareKeys.company(companyId) });
onSaved();
},
});
const error = mutation.error;
return (
<>
<form
className="flex min-h-0 flex-1 flex-col overflow-hidden"
onSubmit={form.handleSubmit((values) => mutation.mutate(values))}
>
<FormBody>
<fieldset className="space-y-4" disabled={mutation.isPending}>
<div className="space-y-2">
<Label htmlFor={`${prefix}-resource`}>{t("software.resource")}</Label>
<SelectWithAction
action={
installation
? undefined
: { label: t("software.newOs"), onClick: () => setQuick("os") }
}
disabled={!!installation}
id={`${prefix}-resource`}
label={t("software.resource")}
onValueChange={(value) => form.setValue("resourceId", value)}
options={options}
placeholder={t("mail.choose")}
required
value={form.watch("resourceId")}
/>
</div>
{!installation && (
<Button onClick={() => setQuick("application")} type="button" variant="link">
{t("applications.newResource")}
</Button>
)}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`${prefix}-version`}>{t("applications.version")}</Label>
<Input id={`${prefix}-version`} maxLength={100} {...form.register("version")} />
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-state`}>{t("status")}</Label>
<SelectWithAction
id={`${prefix}-state`}
label={t("status")}
onValueChange={(value) => {
const parsed = SoftwareInstallationInputSchema.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")}
value={form.watch("state")}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-notes`}>{t("mail.notes")}</Label>
<Textarea id={`${prefix}-notes`} maxLength={4000} {...form.register("notes")} />
</div>
</fieldset>
{Object.keys(form.formState.errors).length > 0 && (
<p className="text-sm text-destructive" role="alert">
{t("software.errors.INVALID_REQUEST")}
</p>
)}
{error && (
<p className="text-sm text-destructive" role="alert">
{t(
`software.errors.${error instanceof SoftwareApiError ? error.code : "INTERNAL_ERROR"}`
)}
</p>
)}
<ContextHelp
description={t("software.description")}
examples={t("software.examples")}
examplesLabel={t("mail.examplesLabel")}
guidance={t("software.help")}
title={t("applications.help")}
/>
</FormBody>
<FormActions>
<Button disabled={mutation.isPending} type="submit">
{t(mutation.isPending ? "saving" : "save")}
</Button>
</FormActions>
</form>
{quick && (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
setQuick(undefined);
}
}}
open
title={t(quick === "os" ? "software.newOs" : "applications.newResource")}
wide
>
<ResourceEditor
allowQuickCreate={false}
companyId={companyId}
initialKind={quick}
onSaved={(id) => {
form.setValue("resourceId", id);
setQuick(undefined);
}}
/>
</FormDialog>
)}
</>
);
}

View File

@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
import type { CompanyDTO } from "../../../../common";
import { PersonAvatar } from "../../../people/shared";
import { SoftwareInstallationsPanel } from "../../../software-installations/list/ui/software-installations-panel";
import { WorkProfileEditorDialog } from "../../../work-profiles/shared";
import { useWorkstationDetailController } from "../controllers/use-workstation-detail-controller";
@ -106,6 +107,11 @@ export function WorkstationDetailPage({
companyId={company.id}
workstation={workstation}
/>
<SoftwareInstallationsPanel
assetId={workstation.values.computerAssetId}
canCreate={workstation.values.active}
companyId={company.id}
/>
<section aria-labelledby="workstation-profiles" className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="text-lg font-semibold" id="workstation-profiles">

View File

@ -10,7 +10,7 @@ export interface IResourceInventoryService {
id: UniqueID;
name: string;
active: boolean;
usage: "application" | "certificate" | "support" | null;
usage: "application" | "certificate" | "support" | "os" | null;
expiresOn: string | null;
}[]
>;
@ -57,6 +57,8 @@ function resourceUsage(key: string | undefined, definition: ResourceDefinitionSp
return "application";
case "template:certificate":
return "certificate";
case "template:os":
return "os";
case "template:support":
return "support";
default:

View File

@ -128,6 +128,7 @@ const TEMPLATES: readonly [string, string, readonly string[]][] = [
["access", "Recurso de acceso", ["reference"]],
["shared_folder", "Carpeta compartida", ["unc", "server", "description"]],
["support", "Herramienta de asistencia remota", ["provider", "url", "description"]],
["os", "Sistema operativo", ["provider", "software_edition", "description"]],
["application", "Aplicación", ["provider", "url", "description"]],
["certificate", "Certificado digital", ["holder", "issuer", "expires", "fingerprint", "purpose"]],
[
@ -209,7 +210,7 @@ export function seedResourceCatalog(catalog: ResourceCatalog) {
add(`template:${key}`, {
kind: "template",
...(key === "support" ? { usage: "support" as const } : {}),
...(key === "support" || key === "os" ? { usage: key } : {}),
name,
description: "",
fields: references,

View File

@ -30,7 +30,7 @@ const field = {
test("catálogo de recursos: serie idempotente, UUID v7, características opcionales y protección", () => {
const catalog = seedResourceCatalog(ResourceCatalog.create());
assert.equal(catalog.definitions.length, 27);
assert.equal(catalog.definitions.length, 28);
assert.equal(seedResourceCatalog(catalog), catalog);
assert.ok(catalog.definitions.every((item) => item.id.toPrimitive()[14] === "7"));
assert.ok(
@ -221,7 +221,7 @@ test("licencias: ampliar un catálogo previo conserva identidades y disponibilid
assert.ok(disabled.isSuccess);
const upgraded = seedResourceCatalog(disabled.data);
assert.equal(upgraded.definitions.length, 27);
assert.equal(upgraded.definitions.length, 28);
for (const existing of disabled.data.definitions) {
assert.equal(
upgraded.definitions.find((item) => item.id.equals(existing.id)),
@ -341,3 +341,15 @@ test("campos globales de serie editables: listas ampliables, tipo fijo y opcione
assert.ok(!removedOption.isSuccess);
assert.equal(removedOption.error.code, "FIELD_TYPE_FIXED");
});
test("sistema operativo: plantilla con uso explícito, copia y ampliación sin sobrescribir definiciones", () => {
const catalog = seedResourceCatalog(ResourceCatalog.create());
const template = catalog.definitions.find(
(item) => item.stockKey.getOrUndefined() === "template:os"
);
assert.ok(template && template.values.data.kind === "template");
assert.equal(template.values.data.usage, "os");
assert.equal(template.values.data.fields.length, 3);
assert.equal(seedResourceCatalog(catalog), catalog);
});

View File

@ -34,7 +34,7 @@ export const ResourceDefinitionSchema = z.discriminatedUnion("kind", [
description: z.string().max(1000),
fields: z.array(TemplateFieldSchema).min(1).max(100),
sections: z.array(TemplateSectionSchema).max(30).optional(),
usage: z.enum(["application", "certificate", "support"]).optional(),
usage: z.enum(["application", "certificate", "support", "os"]).optional(),
})
.refine((template) => validTemplateSections(template.sections ?? [], template.fields), {
path: ["sections"],

View File

@ -58,12 +58,13 @@
"editField": "Edit field",
"newTemplate": "New template",
"templateUsage": "Template use",
"templateUsageHelp": "Allows these resources to be used as profile applications or certificates, or as remote assistance tools. Usage is fixed after creation.",
"templateUsageHelp": "Allows resources to be used as profile applications, operating systems, certificates or remote assistance tools. Usage is fixed after template creation.",
"usage": {
"general": "General",
"application": "Application",
"certificate": "Digital certificate",
"support": "Remote assistance"
"support": "Remote assistance",
"os": "Operating system"
},
"editTemplate": "Edit template",
"name": "Name",

View File

@ -58,12 +58,13 @@
"editField": "Editar campo",
"newTemplate": "Nueva plantilla",
"templateUsage": "Uso de la plantilla",
"templateUsageHelp": "Permite usar estos recursos como aplicaciones o certificados de perfiles, o como herramientas de asistencia remota. El uso queda fijo tras el alta.",
"templateUsageHelp": "Permite utilizar estos recursos como aplicaciones de perfiles, sistemas operativos, certificados o herramientas de asistencia remota. El uso se fija al crear la plantilla.",
"usage": {
"general": "General",
"application": "Aplicación",
"certificate": "Certificado digital",
"support": "Asistencia remota"
"support": "Asistencia remota",
"os": "Sistema operativo"
},
"editTemplate": "Editar plantilla",
"name": "Nombre",

View File

@ -8,5 +8,9 @@ export function templateUsage(source: CatalogItemDTO | undefined) {
return "certificate";
}
if (source?.builtinKey === "template:os") {
return "os";
}
return source?.values.kind === "template" ? source.values.usage : undefined;
}

View File

@ -50,16 +50,23 @@ export function CatalogTemplateForm(props: CatalogTemplateFormProps) {
id={`${id}-usage`}
label={t("templateUsage")}
onValueChange={(value) => {
if (value === "application" || value === "certificate" || value === "support") {
if (
value === "application" ||
value === "certificate" ||
value === "support" ||
value === "os"
) {
form.setValue("usage", value);
} else {
form.setValue("usage", undefined);
}
}}
options={["general", "application", "certificate", "support"].map((value) => ({
value,
label: t(`usage.${value}`),
}))}
options={["general", "application", "certificate", "support", "os"].map(
(value) => ({
value,
label: t(`usage.${value}`),
})
)}
placeholder={t("choose")}
value={form.watch("usage") ?? "general"}
/>

View File

@ -50,7 +50,7 @@ export function requireMailboxAddress(
export function resourceDefaults(
items: readonly CatalogItemDTO[],
resource: ResourceDTO | undefined,
initialKind: "mailbox" | "access" | "application" | "certificate" | "support" | undefined,
initialKind: "mailbox" | "access" | "application" | "certificate" | "support" | "os" | undefined,
initialAddress: string,
initialIntegration: ResourceInput["integration"]
): ResourceInput {

View File

@ -1,7 +1,7 @@
export type ResourceEditorProps = {
companyId: string;
resourceId?: string | undefined;
initialKind?: "mailbox" | "access" | "application" | "certificate" | "support" | undefined;
initialKind?: "mailbox" | "access" | "application" | "certificate" | "support" | "os" | undefined;
initialAddress?: string;
initialAccess?:
| {

View File

@ -7,7 +7,7 @@ export type ResourceFormProps = {
items: readonly CatalogItemDTO[];
onSaved: (id: string) => void;
allowQuickCreate?: boolean;
initialKind?: "mailbox" | "access" | "application" | "certificate" | "support" | undefined;
initialKind?: "mailbox" | "access" | "application" | "certificate" | "support" | "os" | undefined;
initialAddress?: string;
initialIntegration?: ResourceInput["integration"];
};