Perfiles de trabajo

This commit is contained in:
David Arranz 2026-10-05 09:53:29 +02:00
parent e099b8c507
commit 3e6066b79b
47 changed files with 2239 additions and 109 deletions

View File

@ -9,7 +9,10 @@ Aplicación interna para soporte técnico a pequeñas empresas.
- [Compañías, contactos y sedes](docs/companias-contactos-y-sedes.md)
- [Personas y departamentos](docs/personas-y-departamentos.md)
- [Cuentas, grupos y recursos](docs/cuentas-grupos-y-recursos.md)
- [Inventario de activos](docs/inventario-de-activos.md)
- [Puestos de trabajo](docs/puestos-de-trabajo.md)
- [Perfiles de trabajo](docs/perfiles-de-trabajo.md)
- [Componentes de interfaz](docs/componentes-de-interfaz.md)
- [Alcance y siguientes entregas](docs/plan-de-desarrollo.md)
Incluye acceso, sesiones persistentes, gestión de usuarios y compañías clientes con personas, departamentos, asignaciones con historial y sedes, junto con catálogos informáticos, cuentas, grupos, recursos, permisos y necesidades con cobertura calculada. Inventario y tickets se incorporarán progresivamente.
Incluye acceso, sesiones persistentes, gestión de usuarios y compañías clientes con personas, departamentos, asignaciones con historial y sedes, junto con catálogos informáticos, cuentas, grupos, recursos, permisos y necesidades con cobertura calculada. También incluye inventario configurable, puestos con activos exclusivos y compartidos, y la base de perfiles de trabajo por persona y dispositivo. Las configuraciones del entorno y los tickets se incorporarán progresivamente.

View File

@ -25,6 +25,7 @@ import { createIdentityRuntime } from "./infrastructure/http/identity-runtime";
import { verifyInventory } from "./inventory.integration";
import { verifyMail } from "./mail.integration";
import { verifyRelationships } from "./relationships.integration";
import { verifyWorkProfiles } from "./work-profiles.integration";
import { verifyWorkstations } from "./workstations.integration";
test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección del administrador", async () => {
@ -40,6 +41,8 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
await database.query("DELETE FROM http_sessions");
await database.query("DELETE FROM mail_configuration_revisions");
await database.query("DELETE FROM mail_configurations");
await database.query("DELETE FROM work_profile_revisions");
await database.query("DELETE FROM work_profiles");
await database.query("DELETE FROM workstation_assets");
await database.query("DELETE FROM workstation_revisions");
await database.query("DELETE FROM workstations");
@ -720,6 +723,12 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
secondCompany.id,
database
);
await verifyWorkProfiles(
(...args) => operator.request(...args),
customer.id,
secondCompany.id,
database
);
const countAfterAccess = await database.query<{ total: number }>(
"SELECT COUNT(*) AS total FROM users",
{ type: QueryTypes.SELECT }
@ -727,6 +736,11 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
assert.equal(Number(countAfterAccess[0]?.total), 2);
assert.equal((await operator.request("/auth/logout", {})).status, 200);
await expectError(await operator.request("/access/global"), 401, "UNAUTHENTICATED");
await expectError(
await operator.request(`/work-profiles/${customer.id}`),
401,
"UNAUTHENTICATED"
);
await expectError(await operator.request(`/assets/${customer.id}`), 401, "UNAUTHENTICATED");
assert.equal((await operator.session()).user, null);
await expectError(

View File

@ -17,6 +17,7 @@ export async function createIdentityRuntime(
const companies = await createCompanyModule(database, identity.authenticate);
const assets = await createAssetModule(database, identity.authenticate);
const mail = await companies.initializeMail(assets.services.inventory);
const profiles = await companies.initializeProfiles(assets.services.inventory);
const SequelizeStore = connectSequelize(session.Store);
const store = new SequelizeStore({
db: database,
@ -42,6 +43,7 @@ export async function createIdentityRuntime(
companies.register(app);
assets.register(app);
mail.register(app);
profiles.register(app);
},
middleware,
stop: () => store.stopExpiringSessions(),

View File

@ -0,0 +1,187 @@
import assert from "node:assert/strict";
import { InventoryResponseSchema } from "@metis/assets/common";
import {
type AccessFieldsDTO,
AccessResponseSchema,
CompanyResponseSchema,
WorkProfileResponseSchema,
WorkProfilesResponseSchema,
} from "@metis/companies/common";
import type { Sequelize } from "sequelize";
import { z } from "zod/v4";
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
export async function verifyWorkProfiles(
request: Request,
companyId: string,
otherId: string,
database: Sequelize
) {
const path = `/work-profiles/${companyId}`;
const inventory = InventoryResponseSchema.parse(
await (await request(`/assets/${companyId}`)).json()
);
const first = inventory.assets[0];
const second = inventory.assets[1];
assert.ok(first && second);
for (const item of [first, second]) {
if (item.values.status === "retired") {
assert.equal(
(
await request(`/assets/${companyId}/${item.id}/update`, {
values: { ...item.values, status: "available" },
})
).status,
200
);
}
}
const fields = {
name: "Persona perfiles",
position: null,
email: null,
phone: null,
active: true,
isContact: false,
};
const people = await request(`/companies/${companyId}/people`, fields);
assert.equal(people.status, 201);
const person = CompanyResponseSchema.parse(await people.json()).company.people.at(-1);
assert.ok(person);
const foreign = CompanyResponseSchema.parse(await (await request(`/companies/${otherId}`)).json())
.company.people[0];
assert.ok(foreign);
const input = {
name: "Perfil Ana",
personId: person.id,
assetId: first.id,
accountId: null,
active: true,
notes: null,
};
const save = async (values: unknown, id?: string) => {
const response = await request(path + (id ? `/${id}/update` : ""), { values });
assert.equal(response.status, id ? 200 : 201, await response.clone().text());
return WorkProfileResponseSchema.parse(await response.json()).profile;
};
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, await response.clone().text());
assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code);
};
await error({ ...input, personId: foreign.id }, "PROFILE_REFERENCE_NOT_FOUND", undefined, 404);
await error({ ...input, actorId: person.id }, "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 responses = await Promise.all([
request(path, { values: input }),
request(path, { values: input }),
]);
assert.deepEqual(responses.map((response) => response.status).sort(), [201, 409]);
const response = responses.find((response) => response.status === 201);
assert.ok(response);
const created = WorkProfileResponseSchema.parse(await response.json()).profile;
assert.equal(created.id.charAt(14), "7");
assert.equal(created.history[0]?.id.charAt(14), "7");
assert.ok(created.history[0]?.actorName);
assert.deepEqual(await save(input, created.id), created);
const another = await save({ ...input, assetId: second.id });
assert.equal(another.values.personId, created.values.personId);
const otherPersonResponse = await request(`/companies/${companyId}/people`, {
...fields,
name: "Otra persona perfiles",
});
const otherPerson = CompanyResponseSchema.parse(
await otherPersonResponse.json()
).company.people.at(-1);
assert.ok(otherPerson);
await save({ ...input, personId: otherPerson.id });
await error({ ...input, assetId: second.id }, "PROFILE_TARGET_IMMUTABLE", created.id);
const createAccess = async (values: AccessFieldsDTO) => {
const res = await request(`/access/${companyId}`, { values });
assert.equal(res.status, 201, await res.clone().text());
const item = AccessResponseSchema.parse(await res.json()).items.at(-1);
assert.ok(item);
return item;
};
const system = await createAccess({ kind: "system", name: "Dominio perfiles", active: true });
const account = await createAccess({
kind: "account",
systemId: system.id,
environmentId: null,
login: "ana-perfil",
usage: "personal",
active: true,
expiresOn: null,
notes: null,
});
await error({ ...input, accountId: account.id }, "PROFILE_REFERENCE_UNAVAILABLE", created.id);
await createAccess({
kind: "account_person",
accountId: account.id,
personId: person.id,
startDate: new Date().toISOString().slice(0, 10),
endDate: null,
configuration: "pending",
});
const withAccount = await save({ ...input, accountId: account.id }, created.id);
assert.equal(withAccount.history.length, 2);
assert.equal(withAccount.history[0]?.values.accountId, null);
assert.equal(
(
await request(`/access/${companyId}/${account.id}/update`, {
values: { ...account.values, active: false },
})
).status,
200
);
const reviewed = WorkProfilesResponseSchema.parse(
await (await request(path)).json()
).profiles.find((item) => item.id === created.id);
assert.equal(reviewed?.needsReview, true);
const archived = await save({ ...withAccount.values, active: false }, created.id);
assert.equal(archived.needsReview, false);
await error(withAccount.values, "PROFILE_REFERENCE_UNAVAILABLE", created.id);
const replacement = await save(input);
assert.notEqual(replacement.id, created.id);
assert.equal(
(await request(`/work-profiles/${otherId}/${created.id}/update`, { values: input })).status,
404
);
assert.equal(
WorkProfilesResponseSchema.parse(await (await request(`/work-profiles/${otherId}`)).json())
.profiles.length,
0
);
await database.query("UPDATE work_profiles SET currentKey=NULL WHERE id=:id", {
replacements: { id: replacement.id },
});
try {
assert.equal((await request(path)).status, 500);
} finally {
await database.query("UPDATE work_profiles SET currentKey=:key WHERE id=:id", {
replacements: { id: replacement.id, key: [person.id, first.id].join(":") },
});
}
const listed = WorkProfilesResponseSchema.parse(await (await request(path)).json());
assert.deepEqual(
listed.profiles.find((item) => item.id === created.id),
archived
);
assert.equal(
(
await request(`/companies/${companyId}/people/${person.id}/update`, {
...fields,
active: false,
})
).status,
200
);
assert.equal(
WorkProfilesResponseSchema.parse(await (await request(path)).json()).profiles.find(
(item) => item.id === replacement.id
)?.needsReview,
true
);
}

View File

@ -45,6 +45,7 @@ export async function verifyWorkstations(
siteId: own.sites[0]?.id ?? null,
personId: person.id,
assetIds: [first.id, second.id],
sharedAssetIds: [],
};
const save = async (values: unknown, id?: string) => {
const response = await request(id ? `${path}/${id}/update` : path, { values });
@ -133,4 +134,81 @@ export async function verifyWorkstations(
.length,
0
);
// Las instantáneas anteriores se leen sin reescribir identidades ni historia.
const legacyRoot = { ...restored.values };
const { sharedAssetIds: _legacyShared, ...legacyValues } = legacyRoot;
await database.query(
"UPDATE workstations SET " +
String.fromCharCode(96) +
"values" +
String.fromCharCode(96) +
"=:values WHERE id=:id",
{
replacements: { values: JSON.stringify(legacyValues), id: restored.id },
}
);
await database.query(
"UPDATE workstation_revisions SET `values`=JSON_REMOVE(`values`, '$.sharedAssetIds') WHERE workstationId=:id",
{ replacements: { id: restored.id } }
);
try {
assert.deepEqual(
InventoryResponseSchema.parse(
await (await request(`/assets/${companyId}`)).json()
).workstations.find((item) => item.id === restored.id),
restored
);
} finally {
for (const revision of restored.history) {
await database.query("UPDATE workstation_revisions SET `values`=:values WHERE id=:id", {
replacements: { id: revision.id, values: JSON.stringify(revision.values) },
});
}
await database.query(
"UPDATE workstations SET " +
String.fromCharCode(96) +
"values" +
String.fromCharCode(96) +
"=:values WHERE id=:id",
{
replacements: { values: JSON.stringify(restored.values), id: restored.id },
}
);
}
await error(
{ ...input, personId: null, assetIds: [], sharedAssetIds: [first.id] },
"ASSET_IN_WORKSTATION"
);
await error(
{ ...input, personId: null, assetIds: [], sharedAssetIds: [foreign.id] },
"ASSET_NOT_FOUND",
undefined,
404
);
await error({ ...input, sharedAssetIds: [first.id] }, "INVALID_MEMBERS");
await save({ ...restored.values, active: false }, restored.id);
const sharedInput = { ...input, personId: null, assetIds: [], sharedAssetIds: [first.id] };
const sharedOne = await save({ ...sharedInput, name: "Compartido uno" });
const sharedTwo = await save({ ...sharedInput, name: "Compartido dos" });
await error({ ...sharedInput, assetIds: [first.id], sharedAssetIds: [] }, "ASSET_IN_WORKSTATION");
await save({ ...sharedOne.values, sharedAssetIds: [] }, sharedOne.id);
await error({ ...sharedInput, assetIds: [first.id], sharedAssetIds: [] }, "ASSET_IN_WORKSTATION");
await save({ ...sharedTwo.values, active: false }, sharedTwo.id);
const exclusive = await save({ ...sharedInput, assetIds: [first.id], sharedAssetIds: [] });
await error({ ...sharedTwo.values, active: true }, "ASSET_IN_WORKSTATION", sharedTwo.id);
await save({ ...exclusive.values, active: false }, exclusive.id);
const race = await Promise.all([
request(path, {
values: {
...sharedInput,
name: "Reserva concurrente",
assetIds: [second.id],
sharedAssetIds: [],
},
}),
request(path, {
values: { ...sharedInput, name: "Uso compartido concurrente", sharedAssetIds: [second.id] },
}),
]);
assert.deepEqual(race.map((response) => response.status).sort(), [201, 409]);
}

View File

@ -53,6 +53,8 @@ AssetRelationship es la raíz de un vínculo entre dos activos de una compañía
Workstation es la raíz de cada puesto y WorkstationRevision una entidad con UUID propio para cada instantánea. WorkstationValues encapsula el nombre, estado, referencias opcionales y composición. Las referencias técnicas de reserva se derivan del agregado. Consultar [Puestos de trabajo](../puestos-de-trabajo.md).
WorkProfile es la raíz del perfil de una persona en un dispositivo; WorkProfileRevision conserva cada instantánea con UUID propio. WorkProfileValues encapsula Name, referencias UniqueID y opcionales Maybe. Consultar [Perfiles de trabajo](../perfiles-de-trabajo.md).
MailConfiguration es el agregado de un correo configurado en un dispositivo; MailConfigurationRevision conserva cada instantánea con UUID propio. MailConfigurationValues encapsula referencias UniqueID y dirección EmailAddress. Consultar [Correos en dispositivos](../correos-en-dispositivos.md).
Los módulos de documentos y tickets todavía no existen. Se declararán como agregados o entidades al implementar sus operaciones y reglas de consistencia. La decisión de pertenencia a un agregado se toma por sus invariantes y transacciones; no convierte automáticamente cada tabla en una raíz ni fuerza todo el cliente y sus tickets dentro de un solo agregado.

View File

@ -42,3 +42,7 @@ POST /api/mail-configurations/:companyId crea y POST /api/mail-configurations/:c
Esta entrega registra correos en activos corporativos existentes, incluidos smartphones registrados en el inventario. El registro mínimo de dispositivos personales, listas de baja, configuraciones de impresoras/recursos, licencias y roles quedan pendientes. No almacena credenciales ni configura aplicaciones de correo automáticamente.
Las pruebas incluyen varias cuentas y dispositivos, cuentas compartidas, fechas, exclusividad concurrente, identidad e historial, pertenencia, autenticación, origen/CSRF, retirada tras cerrar permisos y desactivar personas, corrupción de persistencia, textos es/en y ayuda inferior. El navegador se comprueba con datos de prueba aislados.
## Evolución acordada
El contrato implementado en esta entrega exige una cuenta informática. Está pendiente adaptarlo a buzones independientes, corporativos o externos, y configuraciones por perfil; la cuenta informática dejará de ser obligatoria. La base de [perfiles de trabajo](perfiles-de-trabajo.md) ya está disponible. No se han migrado ni vinculado automáticamente los correos existentes a perfiles.

View File

@ -0,0 +1,45 @@
# Perfiles de trabajo
## Uso y alcance
La sección Perfiles de trabajo de cada compañía registra el entorno de una persona en un dispositivo concreto. Nuevo perfil aparece a la derecha de la cabecera y abre un modal. Editar y Ficha e historial son acciones explícitas. Los perfiles archivados quedan ocultos inicialmente y pueden incluirse mediante una casilla.
Nombre, persona y dispositivo son obligatorios. Cuenta de inicio de sesión y notas son opcionales. La persona no recibe acceso a Metis Desk. El dispositivo es un activo de la compañía; puede alojar perfiles de varias personas y una persona puede tener perfiles en varios dispositivos. No se deduce el tipo de dispositivo del nombre de la plantilla: las plantillas son configurables. La ayuda pide seleccionar un equipo que aloje la configuración.
La cuenta informática describe el inicio de sesión, no un buzón de correo ni una licencia. Seleccionarla no configura el equipo. Si se indica, debe ser una cuenta personal o compartida, activa y no caducada, de esta compañía, cuyo sistema y entorno estén activos. Debe existir una autorización vigente de la persona en estado pendiente o configurado. Las cuentas técnicas no representan su identidad de inicio de sesión. La caducidad incluye el día indicado; el final de una autorización es exclusivo.
Persona y dispositivo son la identidad funcional del perfil y no cambian después del alta. Cambiar de ordenador supone crear otro perfil y revisar el anterior. Nombre, cuenta, notas y estado pueden cambiar y conservan historial. Solo puede existir un perfil vigente para cada pareja persona/dispositivo. Los archivados se conservan; reactivar comprueba referencias y duplicados. Archivar no elimina configuraciones del equipo ni retira permisos externos.
## Persona, puesto y activo
Un puesto agrupa activos; el perfil pertenece a una persona y a un dispositivo. No se almacena otra asignación de puesto en el perfil: la ficha consulta los puestos vigentes que incluyen el dispositivo. Esta relación no presupone que todas las personas que usan un ordenador sean titulares del puesto o responsables del activo.
La persona asignada al activo, la persona del puesto y las personas con perfiles tienen significados distintos. No se propagan cambios automáticamente entre ellas. Ninguna de estas referencias demuestra una autorización ni que una configuración esté lista.
Los puestos distinguen activos exclusivos y recursos compartidos. Consultar [Puestos de trabajo](puestos-de-trabajo.md).
## Revisión y altas rápidas
Un perfil vigente muestra un aviso si la persona se desactiva, el equipo se retira o la cuenta pierde disponibilidad o autorización. Se conservan sus valores y revisiones. Se permite editar nombre/notas y archivarlo aunque necesite revisión. Crear, reactivar o cambiar la cuenta de un perfil vigente exige referencias disponibles. Las referencias siempre deben pertenecer a la compañía, incluso al archivar.
Persona, dispositivo y cuenta reutilizan SelectWithAction para altas en un modal adicional. Se conserva el formulario padre y el elemento guardado queda seleccionado. La autorización de cuenta también puede registrarse mediante una acción explícita. El editor adicional es compartido con los correos; no abre un tercer nivel de alta. Cancelar mantiene los valores del padre. Una creación ya guardada permanece aunque se cancele el perfil.
La ayuda aparece inmediatamente antes del pie con Guardar y Cancelar. Se mantienen traducciones es/en y estados de carga, error y vacío.
## Arquitectura y persistencia
WorkProfile es un AggregateRoot con UniqueID; WorkProfileRevision es una DomainEntity con su propio UUID. WorkProfileValues es un Value Object inmutable con Name, UniqueID y Maybe para los opcionales. Crear genera UUID v7; actualizar conserva identidad, compara valores semánticamente y añade una revisión solo si cambian. Rehidratar conserva UUID e historial.
work_profiles conserva raíz, referencias y clave única nullable de la pareja vigente. work_profile_revisions conserva instantáneas ordenadas, cuenta histórica, actor autenticado y fecha UTC, con UUID propio y claves foráneas. Las migraciones explícitas son idempotentes. No hay borrado de perfiles ni de sus revisiones.
El repositorio bloquea la compañía antes de validar y guardar. Personas y cuentas usan ese mismo bloqueo, y el inventario se consulta mediante un servicio público de assets, sin importar sus modelos ORM. Dos altas concurrentes de la misma pareja no pueden producir dos perfiles vigentes. El mapper comprueba identidad, referencias, cuenta histórica, orden y fechas, última revisión y clave vigente. Una inconsistencia falla explícitamente.
GET /api/work-profiles/:companyId devuelve los perfiles y sus avisos. POST /api/work-profiles/:companyId y POST /api/work-profiles/:companyId/:id/update reciben values. Requieren sesión; las escrituras usan origen y CSRF. El actor procede de la sesión, nunca del cuerpo de la petición.
## Comprobaciones y siguiente entrega
Las pruebas cubren cuentas opcionales, parejas vigentes únicas, múltiples personas/dispositivos, UUID, idempotencia, identidad funcional fija, revisiones, disponibilidad, caducidad, autorizaciones, aislamiento, concurrencia, persistencia y protección HTTP. Las pruebas de interfaz comprueban traducciones, ausencia de formularios junto a listados, altas rápidas y ayuda inferior.
Esta entrega establece la base del perfil. Todavía no incorpora buzones independientes ni configura correo, carpetas, programas, impresoras o certificados desde el perfil. La ficha muestra explícitamente que esas configuraciones no están registradas; no calcula una preparación ficticia. El módulo actual de correos mantiene su contrato anterior hasta su adaptación, preservando datos e historial. La vista completa Entorno actual sigue pendiente y el mapa visual queda aplazado.
La comprobación en navegador verifica la nueva sección, los desplegables de persona y dispositivo, la apertura de las tres altas rápidas y la conservación del formulario al cancelarlas. En puestos se comprueba la carga de una composición anterior, el selector compartido, su incorporación visual y la cancelación sin persistir cambios.

View File

@ -39,9 +39,13 @@ Catálogos globales con adiciones por compañía, entornos, cuentas con uso fijo
Catálogo global con 30 campos y 12 plantillas de serie protegidas, cinco tipos de dato, copias personalizables, adiciones por compañía y ajustes locales de disponibilidad. Características opcionales por defecto, modales con ayuda y alta rápida de campos desde las plantillas. Consultar [Campos y plantillas de activos](campos-y-plantillas-de-activos.md).
## 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.
## Siguientes entregas: orden actualizado
1. Perfil de configuración por dispositivo: correos con POP/IMAP, carpetas compartidas, impresoras y recursos.
1. Adaptación de correos al perfil y buzones independientes: correos con POP/IMAP, carpetas compartidas, impresoras y 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.
@ -51,7 +55,7 @@ Este orden se concreta antes de cada entrega. Las licencias y su relación con c
Activos por compañía, plantilla capturada, sede y persona opcionales, cuatro estados, características tipadas y revisiones con actor y fecha. Altas rápidas y consulta del historial mediante modales. Consultar [Inventario de activos](inventario-de-activos.md).
Las [relaciones entre activos](relaciones-entre-activos.md) están implementadas: catálogo global y local, cinco tipos de serie, vínculos en ambos sentidos y retirada con auditoría. Los [puestos de trabajo](puestos-de-trabajo.md) están implementados, sin anidación, con sede y persona opcionales, reserva exclusiva de activos en puestos vigentes e historial.
Las [relaciones entre activos](relaciones-entre-activos.md) están implementadas: catálogo global y local, cinco tipos de serie, vínculos en ambos sentidos y retirada con auditoría. Los [puestos de trabajo](puestos-de-trabajo.md) están implementados, sin anidación, con sede y persona opcionales, composición exclusiva, recursos compartidos entre puestos vigentes e historial.
## Tickets e intervenciones: pendiente

View File

@ -2,15 +2,15 @@
## Uso
La sección Puestos de trabajo de cada compañía agrupa los activos utilizados habitualmente como un conjunto. Un puesto tiene UUID v7, nombre, estado activo/inactivo, sede opcional, persona opcional y hasta 200 activos. Se puede crear vacío y completar posteriormente. Una persona puede tener varios puestos. Los puestos no contienen otros puestos.
La sección Puestos de trabajo de cada compañía agrupa los activos utilizados habitualmente como un conjunto. Un puesto tiene UUID v7, nombre, estado activo/inactivo, sede opcional, persona opcional y hasta 200 activos exclusivos y 200 recursos compartidos. No se admite duplicar un activo ni incluirlo en ambas listas. Se puede crear vacío y completar posteriormente. Una persona puede tener varios puestos. Los puestos no contienen otros puestos.
Nuevo puesto abre un modal desde la cabecera derecha; Editar abre otro modal con los datos existentes. El formulario ofrece selectores con «+» para dar de alta sedes, personas y activos sin perder sus datos. Guardar un elemento relacionado lo selecciona o incorpora al puesto; cancelarlo mantiene el formulario principal. Los elementos guardados sobreviven si posteriormente se cancela el puesto. El alta de activo desde un puesto usa plantillas, sedes y personas existentes para evitar un tercer modal.
La ayuda aparece inmediatamente antes de Guardar y Cancelar. El listado permite buscar por nombre, persona o sede. Historial del puesto muestra las versiones completas, con estado, asignación y composición, fecha y responsable. La ficha de cada activo muestra su puesto vigente.
La ayuda aparece inmediatamente antes de Guardar y Cancelar. El listado permite buscar por nombre, persona o sede. Historial del puesto muestra las versiones completas, con estado, asignación y composición, fecha y responsable. La ficha de cada activo muestra los puestos vigentes en los que se utiliza.
## Reglas
Un activo solo puede pertenecer a un puesto activo. El formulario marca como ocupados los activos de otros puestos y no permite seleccionarlos para un puesto activo. El servidor vuelve a comprobarlo dentro de una transacción y la base de datos protege la reserva con una clave única. Dos altas concurrentes no pueden ocupar el mismo activo.
Un activo exclusivo solo puede pertenecer a un puesto activo. Un recurso compartido puede utilizarse en varios puestos activos, por ejemplo una impresora de red. Un activo reservado como exclusivo no puede figurar como compartido en otro puesto activo; un recurso compartido debe retirarse de todos los otros puestos vigentes antes de reservarlo como exclusivo. El formulario marca como ocupados los activos de otros puestos y no permite seleccionarlos en una composición incompatible. Las reservas exclusivas y usos compartidos se validan bajo el mismo bloqueo de compañía, también frente a escrituras concurrentes. El servidor vuelve a comprobarlo dentro de una transacción y la base de datos protege la reserva con una clave única. Dos altas concurrentes no pueden reservar el mismo activo como exclusivo, ni mezclar un uso exclusivo con un uso compartido en otro puesto vigente.
Desactivar un puesto conserva su composición y libera sus reservas. Un activo puede figurar en composiciones de puestos inactivos. Reactivar comprueba de nuevo la disponibilidad: si un activo está ocupado, hay que retirarlo de uno de los puestos antes de guardar. Retirar un activo del puesto no elimina el activo ni sus relaciones.
@ -24,10 +24,14 @@ No se eliminan puestos ni revisiones. Guardar valores normalizados equivalentes
Workstation es un agregado; WorkstationRevision es una entidad interna. WorkstationValues es un Value Object inmutable con nombres Name, referencias UniqueID y opcionales Maybe. Infrastructure reconstruye y valida los valores, la última revisión, la auditoría y el ámbito.
Las tablas workstations y workstation_revisions mantienen raíces y revisiones con claves foráneas a compañía, sede, persona y actor. workstation_assets es una proyección técnica de las referencias utilizadas en todo el historial y de sus reservas vigentes; no representa otra entidad de negocio. Conserva claves foráneas a activos históricos, claves compuestas para la pertenencia a compañía y una clave única nullable para la reserva. Las migraciones son idempotentes.
Las tablas workstations y workstation_revisions mantienen raíces y revisiones con claves foráneas a compañía, sede, persona y actor. workstation_assets es una proyección técnica de las referencias utilizadas en todo el historial y de sus reservas vigentes; no representa otra entidad de negocio. Conserva claves foráneas a activos históricos de ambas listas; solo la composición exclusiva genera la reserva vigente. Las instantáneas anteriores se interpretan con una lista compartida vacía, sin reescribir datos ni UUID. Conserva claves compuestas para la pertenencia a compañía y una clave única nullable para la reserva. Las migraciones son idempotentes.
El repositorio del inventario carga los puestos y serializa sus escrituras con el bloqueo de catálogo y compañía ya utilizado por activos y relaciones. Conserva revisiones existentes y solo inserta las nuevas.
GET /api/assets/:companyId incluye workstations junto a assets y relationships. POST /api/assets/:companyId/workstations y POST /api/assets/:companyId/workstations/:id/update reciben values. Las escrituras requieren sesión, origen válido y CSRF; el actor procede de la sesión y no se admite en el cuerpo.
Las pruebas verifican identidad, composición opcional, límites, igualdad, historial, ámbito, ausencia de anidación, desactivación y reactivación, personas inactivas, concurrencia y persistencia. La comprobación de navegador incluye altas rápidas, cancelación, composición, activos ocupados e historial.
## Perfiles de trabajo
Un puesto agrupa equipos y recursos; el [perfil de trabajo](perfiles-de-trabajo.md) registra la persona en un dispositivo concreto. La composición no crea conexiones físicas, permisos ni perfiles automáticamente. La asignación de un activo no representa propiedad personal: todos los activos del inventario pertenecen a su compañía.

View File

@ -26,7 +26,7 @@ export function mapWorkstationInput(
) {
return Result.fail(new AssetCatalogError("PERSON_UNAVAILABLE"));
}
for (const id of values.assetIds) {
for (const id of [...values.assetIds, ...values.sharedAssetIds]) {
const asset = context.assets.find((item) => item.id.toPrimitive() === id);
if (!asset) {
return Result.fail(new AssetCatalogError("ASSET_NOT_FOUND"));
@ -37,7 +37,9 @@ export function mapWorkstationInput(
(item) =>
item.id.toPrimitive() !== previous?.id.toPrimitive() &&
item.values.active &&
item.values.assetIds.some((assetId) => assetId.toPrimitive() === id)
(item.values.assetIds.some((assetId) => assetId.toPrimitive() === id) ||
(values.assetIds.includes(id) &&
item.values.sharedAssetIds.some((assetId) => assetId.toPrimitive() === id)))
)
) {
return Result.fail(new AssetCatalogError("ASSET_IN_WORKSTATION"));

View File

@ -9,7 +9,8 @@ export class WorkstationValues {
public readonly active: boolean,
public readonly siteId: Maybe<UniqueID>,
public readonly personId: Maybe<UniqueID>,
public readonly assetIds: readonly UniqueID[]
public readonly assetIds: readonly UniqueID[],
public readonly sharedAssetIds: readonly UniqueID[]
) {
Object.freeze(this);
}
@ -22,6 +23,7 @@ export class WorkstationValues {
return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
}
const ids: UniqueID[] = [];
const shared: UniqueID[] = [];
for (const value of parsed.data.assetIds) {
const id = UniqueID.create(value);
if (!id.isSuccess || ids.some((previous) => previous.equals(id.data))) {
@ -29,8 +31,22 @@ export class WorkstationValues {
}
ids.push(id.data);
}
for (const value of parsed.data.sharedAssetIds) {
const id = UniqueID.create(value);
if (!id.isSuccess || [...ids, ...shared].some((previous) => previous.equals(id.data))) {
return Result.fail(new AssetCatalogError("INVALID_MEMBERS"));
}
shared.push(id.data);
}
return Result.ok(
new WorkstationValues(name.data, input.active, site.data, person.data, Object.freeze(ids))
new WorkstationValues(
name.data,
input.active,
site.data,
person.data,
Object.freeze(ids),
Object.freeze(shared)
)
);
}
toPrimitive(): WorkstationInput {
@ -40,6 +56,7 @@ export class WorkstationValues {
siteId: this.siteId.getOrUndefined()?.toPrimitive() ?? null,
personId: this.personId.getOrUndefined()?.toPrimitive() ?? null,
assetIds: this.assetIds.map((id) => id.toPrimitive()),
sharedAssetIds: this.sharedAssetIds.map((id) => id.toPrimitive()),
};
}
equals(other: WorkstationValues) {
@ -51,7 +68,9 @@ export class WorkstationValues {
a.siteId === b.siteId &&
a.personId === b.personId &&
a.assetIds.length === b.assetIds.length &&
a.assetIds.every((id) => b.assetIds.includes(id))
a.assetIds.every((id) => b.assetIds.includes(id)) &&
a.sharedAssetIds.length === b.sharedAssetIds.length &&
a.sharedAssetIds.every((id) => b.sharedAssetIds.includes(id))
);
}
}

View File

@ -5,5 +5,6 @@ export const WorkstationInputSchema = z.strictObject({
siteId: z.uuid().nullable(),
personId: z.uuid().nullable(),
assetIds: z.array(z.uuid()).max(200),
sharedAssetIds: z.array(z.uuid()).max(200),
});
export type WorkstationInput = z.infer<typeof WorkstationInputSchema>;

View File

@ -9,7 +9,14 @@ import type { WorkstationInput } from "./workstation-values";
const assetIds = [UniqueID.generateNewID().toPrimitive(), UniqueID.generateNewID().toPrimitive()];
function input(): WorkstationInput {
return { name: " Puesto Ana ", active: true, siteId: null, personId: null, assetIds };
return {
name: " Puesto Ana ",
active: true,
siteId: null,
personId: null,
sharedAssetIds: [],
assetIds,
};
}
function values(data = input()) {
const result = WorkstationValues.create(data);
@ -70,3 +77,30 @@ test("Puestos: UUID estable e historial de composición, persona y estado; guard
item.id.toPrimitive()
);
});
test("Puestos: recursos compartidos separados, sin duplicados y cambios con historial", () => {
const sharedId = UniqueID.generateNewID().toPrimitive();
const original = values({ ...input(), sharedAssetIds: [sharedId] });
assert.ok(Object.isFrozen(original.sharedAssetIds));
assert.ok(
!WorkstationValues.create({ ...input(), sharedAssetIds: [assetIds[0] ?? ""] }).isSuccess
);
assert.ok(
!WorkstationValues.create({ ...input(), sharedAssetIds: [sharedId, sharedId] }).isSuccess
);
const created = Workstation.create(
UniqueID.generateNewID(),
original,
UniqueID.generateNewID(),
"2026-10-04T10:00:00.000Z"
);
assert.ok(created.isSuccess);
const changed = created.data.update(
values(),
UniqueID.generateNewID(),
"2026-10-04T11:00:00.000Z"
);
assert.ok(changed.isSuccess);
assert.deepEqual(changed.data.history[0]?.values.toPrimitive().sharedAssetIds, [sharedId]);
assert.equal(changed.data.history.length, 2);
});

View File

@ -41,7 +41,9 @@ export async function loadWorkstations(
if (
(values.siteId && !sites.some((site) => site.id === values.siteId)) ||
(values.personId && !people.some((person) => person.id === values.personId)) ||
values.assetIds.some((id) => !assets.some((asset) => asset.id.toPrimitive() === id))
[...values.assetIds, ...values.sharedAssetIds].some(
(id) => !assets.some((asset) => asset.id.toPrimitive() === id)
)
) {
throw new Error("Puesto de trabajo fuera de su compañía.");
}

View File

@ -13,7 +13,14 @@ import type {
} from "./workstation.model";
function valuesOf(raw: unknown, siteId: string | null, personId: string | null) {
const values = WorkstationValues.create(WorkstationInputSchema.parse(raw));
// Las instantáneas anteriores no tenían recursos compartidos. Se interpretan como una lista vacía sin reescribir el historial.
const legacy = WorkstationInputSchema.extend({
sharedAssetIds: z.array(z.uuid()).max(200).optional(),
}).parse(raw);
const values = WorkstationValues.create({
...legacy,
sharedAssetIds: legacy.sharedAssetIds ?? [],
});
if (
!values.isSuccess ||
values.data.toPrimitive().siteId !== siteId ||
@ -51,7 +58,12 @@ export class WorkstationPersistenceMapper {
if (!(history.length && history.at(-1)?.values.equals(values))) {
throw new Error("Última revisión de puesto inconsistente.");
}
const expected = new Set(history.flatMap((item) => item.values.toPrimitive().assetIds));
const expected = new Set(
history.flatMap((item) => [
...item.values.toPrimitive().assetIds,
...item.values.toPrimitive().sharedAssetIds,
])
);
if (
references.length !== expected.size ||
references.some(
@ -88,7 +100,12 @@ export class WorkstationPersistenceMapper {
personId: revision.values.toPrimitive().personId,
})),
references: [
...new Set(item.history.flatMap((revision) => revision.values.toPrimitive().assetIds)),
...new Set(
item.history.flatMap((revision) => [
...revision.values.toPrimitive().assetIds,
...revision.values.toPrimitive().sharedAssetIds,
])
),
].map((assetId) => ({
workstationId: id,
companyId,

View File

@ -22,8 +22,10 @@ export function AssetDetail({
queryKey: inventoryKeys.company(asset.companyId),
queryFn: () => inventoryApi.get(asset.companyId),
});
const workstation = inventory.data?.workstations.find(
(item) => item.values.active && item.values.assetIds.includes(asset.id)
const workstations = inventory.data?.workstations.filter(
(item) =>
item.values.active &&
(item.values.assetIds.includes(asset.id) || item.values.sharedAssetIds.includes(asset.id))
);
const nameOf = (id: string | null, options: readonly { id: string; name: string }[]) =>
id ? (options.find((option) => option.id === id)?.name ?? id) : t("noAssignment");
@ -55,7 +57,7 @@ export function AssetDetail({
</div>
<div>
<dt className="text-sm text-muted-foreground">{t("workstationInAsset")}</dt>
<dd>{workstation?.values.name ?? t("noWorkstation")}</dd>
<dd>{workstations?.map((item) => item.values.name).join(" · ") || t("noWorkstation")}</dd>
</div>
{asset.specification.fields.map((field) => (
<div key={field.fieldId}>

View File

@ -46,10 +46,12 @@ export function WorkstationForm({
siteId: null,
personId: null,
assetIds: [],
sharedAssetIds: [],
},
});
const values = form.watch();
const [quick, setQuick] = useState(false);
const [quickField, setQuickField] = useState<"assetIds" | "sharedAssetIds">("assetIds");
const inventory = useQuery({
queryKey: inventoryKeys.company(companyId),
queryFn: () => inventoryApi.get(companyId),
@ -65,9 +67,20 @@ export function WorkstationForm({
.filter((item) => item.values.active && item.id !== workstation?.id)
.flatMap((item) => item.values.assetIds)
);
const add = (id: string) => {
if (id && mounted.current && !form.getValues("assetIds").includes(id)) {
form.setValue("assetIds", [...form.getValues("assetIds"), id]);
const sharedUsed = new Set(
stations
.filter((item) => item.values.active && item.id !== workstation?.id)
.flatMap((item) => item.values.sharedAssetIds)
);
const unavailable = (field: "assetIds" | "sharedAssetIds", id: string) =>
values.active && (occupied.has(id) || (field === "assetIds" && sharedUsed.has(id)));
const add = (field: "assetIds" | "sharedAssetIds", id: string) => {
if (
id &&
mounted.current &&
![...form.getValues("assetIds"), ...form.getValues("sharedAssetIds")].includes(id)
) {
form.setValue(field, [...form.getValues(field), id]);
}
};
const mutation = useMutation({
@ -136,68 +149,82 @@ export function WorkstationForm({
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-assets`}>{t("addWorkstationAsset")}</Label>
<SelectWithAction
action={{
label: t("newAsset"),
onClick: () => setQuick(true),
disabled: !catalog.data,
}}
disabled={inventory.isPending || values.assetIds.length >= 200}
id={`${prefix}-assets`}
label={t("addWorkstationAsset")}
onValueChange={add}
options={assets
.filter((item) => !values.assetIds.includes(item.id))
.map((item) => ({
value: item.id,
label: occupied.has(item.id)
? t("workstationOccupied", { name: item.values.name })
: item.values.name,
disabled: values.active && occupied.has(item.id),
}))}
placeholder={t("chooseAsset")}
value=""
/>
</div>
{values.assetIds.length ? (
<ul className="space-y-2">
{values.assetIds.map((id) => (
<li
className="flex items-center justify-between gap-3 rounded-md border p-3 text-sm"
key={id}
>
<div>
<p>{assets.find((item) => item.id === id)?.values.name ?? id}</p>
{values.active && occupied.has(id) && (
<p className="text-destructive">
{t("inventoryErrors.ASSET_IN_WORKSTATION")}
</p>
)}
</div>
<Button
aria-label={t("removeWorkstationAsset", {
name: assets.find((item) => item.id === id)?.values.name ?? id,
})}
onClick={() =>
form.setValue(
"assetIds",
values.assetIds.filter((item) => item !== id)
)
}
size="sm"
type="button"
variant="outline"
>
×
</Button>
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">{t("noWorkstationAssets")}</p>
)}
{(["assetIds", "sharedAssetIds"] as const).map((field) => (
<div className="space-y-2" key={field}>
<Label htmlFor={`${prefix}-${field}`}>
{t(field === "assetIds" ? "addWorkstationAsset" : "addSharedWorkstationAsset")}
</Label>
<SelectWithAction
action={{
label: t("newAsset"),
onClick: () => {
setQuickField(field);
setQuick(true);
},
disabled: !catalog.data,
}}
disabled={inventory.isPending || values[field].length >= 200}
id={`${prefix}-${field}`}
label={t(
field === "assetIds" ? "addWorkstationAsset" : "addSharedWorkstationAsset"
)}
onValueChange={(id) => add(field, id)}
options={assets
.filter(
(item) => ![...values.assetIds, ...values.sharedAssetIds].includes(item.id)
)
.map((item) => ({
value: item.id,
label: unavailable(field, item.id)
? t("workstationOccupied", { name: item.values.name })
: item.values.name,
disabled: unavailable(field, item.id),
}))}
placeholder={t("chooseAsset")}
value=""
/>
{values[field].length ? (
<ul className="space-y-2">
{values[field].map((id) => (
<li
className="flex items-center justify-between gap-3 rounded-md border p-3 text-sm"
key={id}
>
<div>
<p>{assets.find((item) => item.id === id)?.values.name ?? id}</p>
{unavailable(field, id) && (
<p className="text-destructive">
{t("inventoryErrors.ASSET_IN_WORKSTATION")}
</p>
)}
</div>
<Button
aria-label={t("removeWorkstationAsset", {
name: assets.find((item) => item.id === id)?.values.name ?? id,
})}
onClick={() =>
form.setValue(
field,
values[field].filter((item) => item !== id)
)
}
size="sm"
type="button"
variant="outline"
>
×
</Button>
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">
{t(field === "assetIds" ? "noWorkstationAssets" : "noSharedWorkstationAssets")}
</p>
)}
</div>
))}
</fieldset>
{Object.keys(form.formState.errors).length > 0 && (
<p className="text-sm text-destructive" role="alert">
@ -218,7 +245,8 @@ export function WorkstationForm({
disabled={
mutation.isPending ||
!inventory.data ||
(values.active && values.assetIds.some((id) => occupied.has(id)))
values.assetIds.some((id) => unavailable("assetIds", id)) ||
values.sharedAssetIds.some((id) => unavailable("sharedAssetIds", id))
}
type="submit"
>
@ -243,7 +271,7 @@ export function WorkstationForm({
items={catalog.data.items}
lookups={lookups}
onSaved={(id) => {
add(id);
add(quickField, id);
setQuick(false);
}}
/>

View File

@ -1,13 +1,17 @@
export const workstationsSpanish = {
workstations: "Puestos de trabajo",
newWorkstation: "Nuevo puesto",
addSharedWorkstationAsset: "Añadir un recurso compartido",
exclusiveWorkstationAssets: "Activos exclusivos",
sharedWorkstationAssets: "Recursos compartidos",
noSharedWorkstationAssets: "Sin recursos compartidos.",
editWorkstation: "Editar puesto",
workstationPerson: "Persona del puesto",
workstationHistory: "Historial del puesto",
workstationAssets: "Activos del puesto",
noWorkstations: "Esta compañía todavía no tiene puestos de trabajo.",
noWorkstationAssets: "Este puesto todavía no tiene activos.",
addWorkstationAsset: "Añadir un activo",
addWorkstationAsset: "Añadir un activo exclusivo",
workstationOccupied: "{{name}} · ocupado en otro puesto",
removeWorkstationAsset: "Retirar {{name}} del puesto",
workstationCount: "{{count}} activos",
@ -18,7 +22,7 @@ export const workstationsSpanish = {
workstationExamples:
"Puesto de Ana: portátil, dos monitores y una impresora. Una persona puede tener varios puestos.",
workstationGuidance:
"Cada activo solo puede estar en un puesto activo. Desactivar conserva la composición y libera sus activos; reactivar comprueba su disponibilidad. El puesto no cambia las asignaciones individuales de los activos.",
"Los activos exclusivos solo pueden estar en un puesto activo. Los recursos compartidos pueden utilizarse en varios puestos, siempre que no estén reservados como exclusivos. Desactivar conserva la composición y libera sus activos; reactivar comprueba su disponibilidad. El puesto no cambia las asignaciones individuales de los activos.",
workstationScope:
"Todos los elementos deben pertenecer a esta compañía. Los cambios de persona, sede y composición conservan historial.",
workstationInAsset: "Puesto de trabajo",
@ -27,13 +31,17 @@ export const workstationsSpanish = {
export const workstationsEnglish = {
workstations: "Workstations",
newWorkstation: "New workstation",
addSharedWorkstationAsset: "Add a shared resource",
exclusiveWorkstationAssets: "Exclusive assets",
sharedWorkstationAssets: "Shared resources",
noSharedWorkstationAssets: "No shared resources.",
editWorkstation: "Edit workstation",
workstationPerson: "Workstation assignee",
workstationHistory: "Workstation history",
workstationAssets: "Workstation assets",
noWorkstations: "This company has no workstations yet.",
noWorkstationAssets: "This workstation has no assets yet.",
addWorkstationAsset: "Add an asset",
addWorkstationAsset: "Add an exclusive asset",
workstationOccupied: "{{name}} · used in another workstation",
removeWorkstationAsset: "Remove {{name}} from workstation",
workstationCount: "{{count}} assets",
@ -44,7 +52,7 @@ export const workstationsEnglish = {
workstationExamples:
"Ana's workstation: laptop, two monitors and a printer. One person can have several workstations.",
workstationGuidance:
"Each asset can belong to one active workstation. Deactivation preserves composition and releases its assets; reactivation checks availability. Workstation assignment does not change individual asset assignments.",
"Exclusive assets can belong to one active workstation. Shared resources can be used in several workstations unless they are reserved as exclusive. Deactivation preserves composition and releases its assets; reactivation checks availability. Workstation assignment does not change individual asset assignments.",
workstationScope:
"All elements must belong to this company. Person, site and composition changes retain history.",
workstationInAsset: "Workstation",

View File

@ -36,9 +36,9 @@ export function WorkstationsPage({
lookups.people.find((item) => item.id === id)?.name ?? t("noAssignment");
const siteName = (id: string | null) =>
lookups.sites.find((item) => item.id === id)?.name ?? t("noAssignment");
const members = (ids: readonly string[]) =>
const members = (ids: readonly string[], empty = "noWorkstationAssets") =>
ids.map((id) => assets.find((item) => item.id === id)?.values.name ?? id).join(" · ") ||
t("noWorkstationAssets");
t(empty);
const filtered = stations.filter((item) =>
[item.values.name, personName(item.values.personId), siteName(item.values.siteId)].some(
(value) => value.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())
@ -51,9 +51,17 @@ export function WorkstationsPage({
</h3>
<p className="text-sm text-muted-foreground">
{siteName(item.values.siteId)} · {personName(item.values.personId)} ·{" "}
{t("workstationCount", { count: item.values.assetIds.length })}
{t("workstationCount", {
count: item.values.assetIds.length + item.values.sharedAssetIds.length,
})}
</p>
<p className="mt-2 text-sm">
{t("exclusiveWorkstationAssets")}: {members(item.values.assetIds)}
</p>
<p className="mt-1 text-sm">
{t("sharedWorkstationAssets")}:{" "}
{members(item.values.sharedAssetIds, "noSharedWorkstationAssets")}
</p>
<p className="mt-2 text-sm">{members(item.values.assetIds)}</p>
</>
);
return (
@ -156,7 +164,13 @@ export function WorkstationsPage({
<p>
{siteName(revision.values.siteId)} · {personName(revision.values.personId)}
</p>
<p>{members(revision.values.assetIds)}</p>
<p>
{t("exclusiveWorkstationAssets")}: {members(revision.values.assetIds)}
</p>
<p>
{t("sharedWorkstationAssets")}:{" "}
{members(revision.values.sharedAssetIds, "noSharedWorkstationAssets")}
</p>
</li>
))}
</ol>

View File

@ -10,7 +10,7 @@
},
"scripts": {
"typecheck": "tsc -p tsconfig.json",
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/components/companies-page.test.tsx src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/web/access/access.test.tsx src/web/mail/mail.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/web/access/access.test.tsx src/web/mail/mail.test.tsx src/api/domain/work-profile.test.ts src/web/profiles/profile.test.tsx"
},
"dependencies": {
"@repo/rdx-ddd": "workspace:*",

View File

@ -0,0 +1,106 @@
import { Result } from "@repo/rdx-utils";
import { WorkProfileValues } from "../domain/value-objects/work-profile-values";
import type { WorkProfile } from "../domain/work-profile.aggregate";
import { WorkProfileError, type WorkProfileInput } from "../domain/work-profile-values";
import type { WorkProfileContext } from "./work-profile.repository";
export function profileReferencesAvailable(values: WorkProfileValues, context: WorkProfileContext) {
if (
!(
context.people.some((person) => person.id.equals(values.personId) && person.active) &&
context.assets.some((asset) => asset.id.equals(values.assetId) && asset.available)
)
) {
return false;
}
const accountId = values.accountId.getOrUndefined();
if (!accountId) {
return true;
}
const account = context.items.find((item) => item.id.equals(accountId))?.values;
if (
!account ||
account.kind !== "account" ||
!account.active ||
account.usage === "technical" ||
(account.expiresOn && account.expiresOn < context.today)
) {
return false;
}
const system = context.items.find((item) => item.id.toPrimitive() === account.systemId)?.values;
const environment = account.environmentId
? context.items.find((item) => item.id.toPrimitive() === account.environmentId)?.values
: undefined;
if (
!system ||
system.kind !== "system" ||
!system.active ||
(account.environmentId &&
(!environment || environment.kind !== "environment" || !environment.active))
) {
return false;
}
return context.items.some((item) => {
const link = item.values;
return (
link.kind === "account_person" &&
link.personId === values.personId.toPrimitive() &&
link.accountId === accountId.toPrimitive() &&
link.startDate <= context.today &&
(link.endDate === null || context.today < link.endDate) &&
(link.configuration === "pending" || link.configuration === "configured")
);
});
}
export function mapWorkProfileInput(
input: WorkProfileInput,
context: WorkProfileContext,
previous?: WorkProfile
) {
const mapped = WorkProfileValues.create(input);
if (!mapped.isSuccess) {
return mapped;
}
const values = mapped.data;
if (previous && !previous.values.sameTarget(values)) {
return Result.fail(new WorkProfileError("PROFILE_TARGET_IMMUTABLE"));
}
if (
!(
context.people.some((person) => person.id.equals(values.personId)) &&
context.assets.some((asset) => asset.id.equals(values.assetId))
)
) {
return Result.fail(new WorkProfileError("PROFILE_REFERENCE_NOT_FOUND"));
}
const accountId = values.accountId.getOrUndefined();
if (
accountId &&
!context.items.some((item) => item.id.equals(accountId) && item.values.kind === "account")
) {
return Result.fail(new WorkProfileError("PROFILE_REFERENCE_NOT_FOUND"));
}
const changingAccount =
previous?.values.toPrimitive().accountId !== values.toPrimitive().accountId;
if (
values.active &&
(!(previous && previous.values.active) || changingAccount) &&
!profileReferencesAvailable(values, context)
) {
return Result.fail(new WorkProfileError("PROFILE_REFERENCE_UNAVAILABLE"));
}
if (
values.active &&
context.profiles.some(
(item) =>
item.id.toPrimitive() !== previous?.id.toPrimitive() &&
item.values.active &&
item.values.sameTarget(values)
)
) {
return Result.fail(new WorkProfileError("PROFILE_ALREADY_ACTIVE"));
}
return mapped;
}

View File

@ -0,0 +1,25 @@
import type { WorkProfileDTO } from "../../common/work-profiles";
import type { WorkProfile } from "../domain/work-profile.aggregate";
import type { WorkProfileContext } from "./work-profile.repository";
import { profileReferencesAvailable } from "./work-profile-input.mapper";
export function buildWorkProfileResponse(
item: WorkProfile,
context: WorkProfileContext
): WorkProfileDTO {
return {
id: item.id.toPrimitive(),
companyId: item.companyId.toPrimitive(),
values: item.values.toPrimitive(),
needsReview: item.values.active && !profileReferencesAvailable(item.values, context),
history: item.history.map((revision) => ({
id: revision.id.toPrimitive(),
values: revision.values.toPrimitive(),
actorId: revision.actorId.toPrimitive(),
actorName:
context.actors.get(revision.actorId.toPrimitive()) ?? revision.actorId.toPrimitive(),
recordedAt: revision.recordedAt,
})),
};
}

View File

@ -0,0 +1,24 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Result } from "@repo/rdx-utils";
import type { AccessDefinition } from "../domain/access/access-definition";
import type { WorkProfile } from "../domain/work-profile.aggregate";
import type { WorkProfileError } from "../domain/work-profile-values";
export interface IWorkProfileInventoryService {
list(companyId: UniqueID): Promise<readonly { id: UniqueID; available: boolean }[]>;
}
export type WorkProfileContext = Readonly<{
profiles: readonly WorkProfile[];
people: readonly { id: UniqueID; active: boolean }[];
assets: readonly { id: UniqueID; available: boolean }[];
items: readonly { id: UniqueID; values: AccessDefinition }[];
actors: ReadonlyMap<string, string>;
today: string;
}>;
export interface IWorkProfileRepository {
get(companyId: UniqueID): Promise<Result<WorkProfileContext, WorkProfileError>>;
save(
companyId: UniqueID,
action: (context: WorkProfileContext) => Result<WorkProfile, WorkProfileError>
): Promise<Result<{ profile: WorkProfile; context: WorkProfileContext }, WorkProfileError>>;
}

View File

@ -0,0 +1,42 @@
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { WorkProfile } from "../domain/work-profile.aggregate";
import type { WorkProfileInput } from "../domain/work-profile-values";
import { WorkProfileError } from "../domain/work-profile-values";
import type { IWorkProfileRepository } from "./work-profile.repository";
import { mapWorkProfileInput } from "./work-profile-input.mapper";
export class WorkProfileUseCases {
constructor(private readonly repository: IWorkProfileRepository) {}
get(companyId: string) {
const company = UniqueID.create(companyId);
return company.isSuccess
? this.repository.get(company.data)
: Promise.resolve(Result.fail(new WorkProfileError("INVALID_REQUEST")));
}
save(companyId: string, id: string | undefined, input: WorkProfileInput, 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 WorkProfileError("INVALID_REQUEST")));
}
return this.repository.save(company.data, (context) => {
const previous = identity?.isSuccess
? context.profiles.find((item) => item.id.equals(identity.data))
: undefined;
if (id && !previous) {
return Result.fail(new WorkProfileError("PROFILE_NOT_FOUND"));
}
const values = mapWorkProfileInput(input, context, previous);
if (!values.isSuccess) {
return Result.fail(values.error);
}
const now = new Date().toISOString();
return previous
? previous.update(values.data, actor.data, now)
: WorkProfile.create(company.data, values.data, actor.data, now);
});
}
}

View File

@ -0,0 +1,71 @@
import { Name, UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import {
WorkProfileError,
type WorkProfileInput,
WorkProfileInputSchema,
} from "../work-profile-values";
export class WorkProfileValues {
private constructor(
public readonly name: Name,
public readonly personId: UniqueID,
public readonly assetId: UniqueID,
public readonly accountId: Maybe<UniqueID>,
public readonly active: boolean,
public readonly notes: Maybe<string>
) {
Object.freeze(this);
}
static create(input: WorkProfileInput): Result<WorkProfileValues, WorkProfileError> {
const parsed = WorkProfileInputSchema.safeParse(input);
if (!parsed.success) {
return Result.fail(new WorkProfileError("INVALID_REQUEST"));
}
const name = Name.create(parsed.data.name, 100);
const person = UniqueID.create(parsed.data.personId);
const asset = UniqueID.create(parsed.data.assetId);
const account = parsed.data.accountId ? UniqueID.create(parsed.data.accountId) : undefined;
if (
!(name.isSuccess && person.isSuccess && asset.isSuccess) ||
(account && !account.isSuccess)
) {
return Result.fail(new WorkProfileError("INVALID_REQUEST"));
}
return Result.ok(
new WorkProfileValues(
name.data,
person.data,
asset.data,
account?.isSuccess ? Maybe.some(account.data) : Maybe.none(),
parsed.data.active,
parsed.data.notes ? Maybe.some(parsed.data.notes) : Maybe.none()
)
);
}
toPrimitive(): WorkProfileInput {
return {
name: this.name.toPrimitive(),
personId: this.personId.toPrimitive(),
assetId: this.assetId.toPrimitive(),
accountId: this.accountId.getOrUndefined()?.toPrimitive() ?? null,
active: this.active,
notes: this.notes.getOrUndefined() ?? null,
};
}
sameTarget(other: WorkProfileValues) {
return this.personId.equals(other.personId) && this.assetId.equals(other.assetId);
}
equals(other: WorkProfileValues) {
const a = this.toPrimitive();
const b = other.toPrimitive();
return (
this.sameTarget(other) &&
a.name === b.name &&
a.accountId === b.accountId &&
a.active === b.active &&
a.notes === b.notes
);
}
}

View File

@ -0,0 +1,25 @@
import { DomainEntity, type UniqueID } from "@repo/rdx-ddd";
import type { WorkProfileValues } from "./value-objects/work-profile-values";
export type WorkProfileRevisionProps = Readonly<{
values: WorkProfileValues;
actorId: UniqueID;
recordedAt: string;
}>;
export class WorkProfileRevision extends DomainEntity<WorkProfileRevisionProps> {
static create(props: WorkProfileRevisionProps) {
return new WorkProfileRevision(Object.freeze({ ...props }));
}
static rehydrate(props: WorkProfileRevisionProps, id: UniqueID) {
return new WorkProfileRevision(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,16 @@
import { z } from "zod/v4";
export const WorkProfileInputSchema = z.strictObject({
name: z.string().trim().min(1).max(100),
personId: z.uuid(),
assetId: z.uuid(),
accountId: z.uuid().nullable(),
active: z.boolean(),
notes: z.string().trim().max(4000).nullable(),
});
export type WorkProfileInput = z.infer<typeof WorkProfileInputSchema>;
export class WorkProfileError extends Error {
constructor(public readonly code: string) {
super(code);
}
}

View File

@ -0,0 +1,75 @@
import { AggregateRoot, type UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { z } from "zod/v4";
import type { WorkProfileValues } from "./value-objects/work-profile-values";
import { WorkProfileRevision } from "./work-profile-revision.entity";
import { WorkProfileError } from "./work-profile-values";
type WorkProfileProps = Readonly<{
companyId: UniqueID;
values: WorkProfileValues;
history: readonly WorkProfileRevision[];
}>;
export class WorkProfile extends AggregateRoot<WorkProfileProps> {
private constructor(props: WorkProfileProps, id?: UniqueID) {
super(Object.freeze({ ...props, history: Object.freeze([...props.history]) }), id);
}
static create(
companyId: UniqueID,
values: WorkProfileValues,
actorId: UniqueID,
recordedAt: string
): Result<WorkProfile, WorkProfileError> {
if (!z.iso.datetime().safeParse(recordedAt).success) {
return Result.fail(new WorkProfileError("INVALID_REQUEST"));
}
return Result.ok(
new WorkProfile({
companyId,
values,
history: [WorkProfileRevision.create({ values, actorId, recordedAt })],
})
);
}
static rehydrate(props: WorkProfileProps, id: UniqueID) {
return new WorkProfile(props, id);
}
get companyId() {
return this.props.companyId;
}
get values() {
return this.props.values;
}
get history() {
return this.props.history;
}
update(
values: WorkProfileValues,
actorId: UniqueID,
recordedAt: string
): Result<WorkProfile, WorkProfileError> {
if (this.values.equals(values)) {
return Result.ok(this);
}
if (!this.values.sameTarget(values)) {
return Result.fail(new WorkProfileError("PROFILE_TARGET_IMMUTABLE"));
}
if (
!z.iso.datetime().safeParse(recordedAt).success ||
recordedAt < (this.history.at(-1)?.recordedAt ?? "")
) {
return Result.fail(new WorkProfileError("INVALID_REQUEST"));
}
return Result.ok(
new WorkProfile(
{
...this.props,
values,
history: [...this.history, WorkProfileRevision.create({ values, actorId, recordedAt })],
},
this.id
)
);
}
}

View File

@ -0,0 +1,182 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { UniqueID } from "@repo/rdx-ddd";
import type { WorkProfileContext } from "../application/work-profile.repository";
import {
mapWorkProfileInput,
profileReferencesAvailable,
} from "../application/work-profile-input.mapper";
import { WorkProfilePersistenceMapper } from "../infrastructure/persistence/work-profile-persistence.mapper";
import { WorkProfileValues } from "./value-objects/work-profile-values";
import { WorkProfile } from "./work-profile.aggregate";
import type { WorkProfileInput } from "./work-profile-values";
const person = UniqueID.generateNewID();
const asset = UniqueID.generateNewID();
const actor = UniqueID.generateNewID();
const company = UniqueID.generateNewID();
const account = UniqueID.generateNewID();
const system = UniqueID.generateNewID();
const now = "2026-10-04T10:00:00.000Z";
function input(): WorkProfileInput {
return {
name: " Ana en portátil ",
personId: person.toPrimitive(),
assetId: asset.toPrimitive(),
accountId: null,
active: true,
notes: null,
};
}
function values(data = input()) {
const result = WorkProfileValues.create(data);
assert.ok(result.isSuccess);
return result.data;
}
function profile(data = input()) {
const result = WorkProfile.create(company, values(data), actor, now);
assert.ok(result.isSuccess);
return result.data;
}
function context(): WorkProfileContext {
return {
profiles: [],
people: [{ id: person, active: true }],
assets: [{ id: asset, available: true }],
items: [],
actors: new Map([[actor.toPrimitive(), "Técnico"]]),
today: "2026-10-04",
};
}
test("Perfil: cuenta opcional, UUID, historial, identidad fija e idempotencia", () => {
const item = profile();
assert.equal(item.id.toPrimitive().charAt(14), "7");
assert.equal(item.history[0]?.id.toPrimitive().charAt(14), "7");
assert.equal(item.values.name.toPrimitive(), "Ana en portátil");
assert.ok(Object.isFrozen(item.values));
const same = item.update(values({ ...input(), notes: " " }), actor, now);
assert.ok(same.isSuccess);
assert.equal(same.data, item);
const update = item.update(
values({ ...input(), accountId: account.toPrimitive(), active: false, notes: "Archivado" }),
actor,
now
);
assert.ok(update.isSuccess);
assert.ok(update.data.id.equals(item.id));
assert.equal(update.data.history.length, 2);
assert.equal(update.data.history[0]?.values.toPrimitive().accountId, null);
assert.ok(
!item.update(values({ ...input(), assetId: actor.toPrimitive() }), actor, now).isSuccess
);
assert.ok(
!item.update(values({ ...input(), personId: actor.toPrimitive() }), actor, now).isSuccess
);
assert.ok(
!item.update(values({ ...input(), name: "Otro" }), actor, "2026-10-03T10:00:00.000Z").isSuccess
);
assert.ok(!WorkProfileValues.create({ ...input(), name: " " }).isSuccess);
assert.ok(!WorkProfileValues.create({ ...input(), assetId: "invalid" }).isSuccess);
});
test("Perfil: misma persona/dispositivo único vigente, múltiples dispositivos/personas y revisión sin pérdida de datos", () => {
const previous = profile();
const ctx = { ...context(), profiles: [previous] };
const duplicate = mapWorkProfileInput(input(), ctx);
assert.ok(!duplicate.isSuccess);
assert.equal(duplicate.error.code, "PROFILE_ALREADY_ACTIVE");
assert.ok(mapWorkProfileInput(input(), ctx, previous).isSuccess);
assert.ok(mapWorkProfileInput({ ...input(), active: false }, ctx).isSuccess);
const inactive = { ...ctx, people: [{ id: person, active: false }] };
assert.equal(profileReferencesAvailable(previous.values, inactive), false);
assert.ok(mapWorkProfileInput({ ...input(), name: "Revisión" }, inactive, previous).isSuccess);
assert.ok(mapWorkProfileInput({ ...input(), active: false }, inactive, previous).isSuccess);
assert.ok(!mapWorkProfileInput(input(), { ...context(), assets: [] }).isSuccess);
const second = UniqueID.generateNewID();
assert.ok(
mapWorkProfileInput(
{ ...input(), assetId: second.toPrimitive() },
{ ...ctx, assets: [...ctx.assets, { id: second, available: true }] }
).isSuccess
);
assert.ok(
mapWorkProfileInput(
{ ...input(), personId: second.toPrimitive() },
{ ...ctx, people: [...ctx.people, { id: second, active: true }] }
).isSuccess
);
});
test("Perfil: cuenta autorizada, vigencia inclusiva de caducidad, cierre de autorización y cuenta técnica", () => {
const ctx: WorkProfileContext = {
...context(),
items: [
{ id: system, values: { kind: "system", name: "Dominio", active: true } },
{
id: account,
values: {
kind: "account",
systemId: system.toPrimitive(),
environmentId: null,
login: "ana",
usage: "personal",
active: true,
expiresOn: "2026-10-04",
notes: null,
},
},
{
id: UniqueID.generateNewID(),
values: {
kind: "account_person",
accountId: account.toPrimitive(),
personId: person.toPrimitive(),
startDate: "2026-10-01",
endDate: null,
configuration: "pending",
},
},
],
};
const data = { ...input(), accountId: account.toPrimitive() };
assert.ok(mapWorkProfileInput(data, ctx).isSuccess);
assert.ok(!mapWorkProfileInput(data, { ...ctx, today: "2026-10-05" }).isSuccess);
for (const configuration of ["removed", "removal_pending", "never_configured"] as const) {
assert.ok(
!mapWorkProfileInput(data, {
...ctx,
items: ctx.items.map((item) =>
item.values.kind === "account_person"
? { ...item, values: { ...item.values, configuration } }
: item
),
}).isSuccess
);
}
assert.ok(
!mapWorkProfileInput(data, {
...ctx,
items: ctx.items.map((item) =>
item.values.kind === "account"
? { ...item, values: { ...item.values, usage: "technical" } }
: item
),
}).isSuccess
);
});
test("Perfil: persistencia conserva revisiones y rechaza raíz o cuenta histórica incoherentes", () => {
const mapper = new WorkProfilePersistenceMapper();
const item = profile();
const rows = mapper.toPersistence(item);
const restored = mapper.toDomain(rows.root, rows.revisions);
assert.ok(restored.id.equals(item.id));
assert.ok(restored.values.equals(item.values));
assert.throws(() => mapper.toDomain({ ...rows.root, currentKey: null }, rows.revisions));
assert.throws(() =>
mapper.toDomain(
rows.root,
rows.revisions.map((row) => ({ ...row, accountId: account.toPrimitive() }))
)
);
});

View File

@ -0,0 +1,79 @@
import { type Application, type RequestHandler, type Response, Router } from "express";
import { z } from "zod/v4";
import {
ProfileItemParamsSchema,
ProfileParamsSchema,
SaveWorkProfileRequestSchema,
} from "../../../common/work-profiles";
import type { WorkProfileUseCases } from "../../application/work-profile.use-cases";
import { buildWorkProfileResponse } from "../../application/work-profile-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 registerWorkProfileRoutes(
app: Application,
useCases: WorkProfileUseCases,
authenticate: RequestHandler
) {
const router = Router();
router.get("/:companyId", (request, response, next) => {
const params = ProfileParamsSchema.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({
profiles: result.data.profiles.map((item) => buildWorkProfileResponse(item, result.data)),
});
})
.catch(next);
});
for (const path of ["/:companyId", "/:companyId/:id/update"]) {
router.post(path, (request, response, next) => {
const params = (
path.endsWith("update") ? ProfileItemParamsSchema : ProfileParamsSchema
).safeParse(request.params);
const body = SaveWorkProfileRequestSchema.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({
profile: buildWorkProfileResponse(result.data.profile, result.data.context),
});
})
.catch(next);
});
}
app.use("/api/work-profiles", authenticate, router);
}

View File

@ -0,0 +1,134 @@
import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { QueryTypes, type Sequelize, type Transaction } from "sequelize";
import type {
IWorkProfileInventoryService,
IWorkProfileRepository,
WorkProfileContext,
} from "../../application/work-profile.repository";
import { AccessDefinitionSchema } from "../../domain/access/access-definition";
import { WorkProfileError } from "../../domain/work-profile-values";
import {
type ProfileRevisionRow,
type ProfileRow,
WorkProfilePersistenceMapper,
persistedJson,
profileIdentity,
} from "./work-profile-persistence.mapper";
export class SequelizeWorkProfileRepository implements IWorkProfileRepository {
private readonly mapper = new WorkProfilePersistenceMapper();
constructor(
private readonly database: Sequelize,
private readonly inventory: IWorkProfileInventoryService
) {}
private async load(
company: UniqueID,
transaction: Transaction,
write: boolean
): Promise<Result<WorkProfileContext, WorkProfileError>> {
const companyId = company.toPrimitive();
// El bloqueo de compañía precede a las lecturas: los escritores de personas, cuentas y activos usan el mismo bloqueo.
const companies = await this.database.query<{ id: string }>(
`SELECT id FROM companies WHERE id=:companyId${write ? " FOR UPDATE" : ""}`,
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
);
if (!companies.length) {
return Result.fail(new WorkProfileError("COMPANY_NOT_FOUND"));
}
const rows = await this.database.query<ProfileRow>(
"SELECT * FROM work_profiles WHERE companyId=:companyId",
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
);
const revisions = await this.database.query<ProfileRevisionRow>(
"SELECT r.* FROM work_profile_revisions r JOIN work_profiles c ON c.id=r.profileId WHERE c.companyId=:companyId",
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
);
const people = await this.database.query<{ id: string; active: boolean }>(
"SELECT id,active FROM company_contacts WHERE companyId=:companyId",
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
);
const items = await this.database.query<{ id: string; values: unknown }>(
"SELECT i.id,i.values FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId WHERE w.companyId=:companyId OR w.companyId IS NULL",
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
);
const actors = await this.database.query<{ id: string; name: string }>(
"SELECT id,name FROM users",
{ transaction, type: QueryTypes.SELECT }
);
const context: WorkProfileContext = {
profiles: rows.map((row) =>
this.mapper.toDomain(
row,
revisions.filter((revision) => revision.profileId === row.id)
)
),
people: people.map((person) => ({
id: profileIdentity(person.id),
active: Boolean(person.active),
})),
items: items.map((item) => ({
id: profileIdentity(item.id),
values: AccessDefinitionSchema.parse(persistedJson(item.values)),
})),
assets: await this.inventory.list(company),
actors: new Map(actors.map((actor) => [actor.id, actor.name])),
today: new Date().toISOString().slice(0, 10),
};
if (
context.profiles.some(
(item) =>
!(
context.people.some((person) => person.id.equals(item.values.personId)) &&
item.history.every(
(revision) =>
!revision.values.accountId.getOrUndefined() ||
context.items.some(
(account) =>
revision.values.accountId.getOrUndefined()?.equals(account.id) &&
account.values.kind === "account"
)
) &&
context.assets.some((asset) => asset.id.equals(item.values.assetId))
)
)
) {
throw new Error("Pertenencia de perfil persistida inconsistente.");
}
return Result.ok(context);
}
get(companyId: UniqueID) {
return this.database.transaction((transaction) => this.load(companyId, transaction, false));
}
save(companyId: UniqueID, action: Parameters<IWorkProfileRepository["save"]>[1]) {
return this.database.transaction(async (transaction) => {
const context = await this.load(companyId, transaction, true);
if (!context.isSuccess) {
return context;
}
const candidate = action(context.data);
if (!candidate.isSuccess) {
return candidate;
}
const previous = context.data.profiles.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 work_profiles (id,companyId,personId,accountId,assetId,`values`,currentKey) VALUES (:id,:companyId,:personId,:accountId,:assetId,:values,:currentKey) ON DUPLICATE KEY UPDATE `values`=VALUES(`values`),accountId=VALUES(accountId),currentKey=VALUES(currentKey)",
{ replacements: record.root, transaction }
);
const stored = new Set(
previous?.history.map((revision) => revision.id.toPrimitive()) ?? []
);
for (const revision of record.revisions.filter((item) => !stored.has(item.id))) {
await this.database.query(
"INSERT INTO work_profile_revisions (id,profileId,accountId,position,actorId,recordedAt,`values`) VALUES (:id,:profileId,:accountId,:position,:actorId,:recordedAt,:values)",
{ replacements: revision, transaction }
);
}
}
return Result.ok({ profile: candidate.data, context: context.data });
});
}
}

View File

@ -0,0 +1,46 @@
import { DataTypes, QueryTypes, type Sequelize } from "sequelize";
export async function migrateWorkProfiles(database: Sequelize) {
const query = database.getQueryInterface();
const rows = await database.query<{ name: string }>(
"SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()",
{ type: QueryTypes.SELECT }
);
const reference = (table: string) => ({
type: DataTypes.UUID,
allowNull: false,
references: { model: table, key: "id" },
onDelete: "RESTRICT",
});
if (!rows.some((row) => row.name === "work_profiles")) {
await query.createTable("work_profiles", {
id: { type: DataTypes.UUID, primaryKey: true },
companyId: reference("companies"),
personId: reference("company_contacts"),
accountId: { ...reference("access_items"), allowNull: true },
assetId: reference("assets"),
values: { type: DataTypes.JSON, allowNull: false },
currentKey: { type: DataTypes.STRING(110), allowNull: true, unique: true },
});
}
if (!rows.some((row) => row.name === "work_profile_revisions")) {
await query.createTable("work_profile_revisions", {
id: { type: DataTypes.UUID, primaryKey: true },
profileId: reference("work_profiles"),
accountId: { ...reference("access_items"), allowNull: true },
position: { type: DataTypes.INTEGER, allowNull: false },
actorId: reference("users"),
recordedAt: { type: DataTypes.STRING(24), allowNull: false },
values: { type: DataTypes.JSON, 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='work_profile_revisions'",
{ type: QueryTypes.SELECT }
);
if (!indexes.some((index) => index.name === "profile_revision_position")) {
await query.addIndex("work_profile_revisions", ["profileId", "position"], {
unique: true,
name: "profile_revision_position",
});
}
}

View File

@ -0,0 +1,104 @@
import { UniqueID } from "@repo/rdx-ddd";
import { z } from "zod/v4";
import { WorkProfileValues } from "../../domain/value-objects/work-profile-values";
import { WorkProfile } from "../../domain/work-profile.aggregate";
import { WorkProfileRevision } from "../../domain/work-profile-revision.entity";
import { WorkProfileInputSchema } from "../../domain/work-profile-values";
export function profileIdentity(value: string) {
const id = UniqueID.create(value);
if (!id.isSuccess) {
throw new Error("Identidad de perfil persistida inconsistente.");
}
return id.data;
}
export function persistedJson(value: unknown): unknown {
return typeof value === "string" ? JSON.parse(value) : value;
}
export type ProfileRow = {
id: string;
companyId: string;
personId: string;
accountId: string | null;
assetId: string;
values: unknown;
currentKey: string | null;
};
export type ProfileRevisionRow = {
id: string;
profileId: string;
accountId: string | null;
position: number;
actorId: string;
recordedAt: string;
values: unknown;
};
function valuesOf(raw: unknown) {
const values = WorkProfileValues.create(WorkProfileInputSchema.parse(persistedJson(raw)));
if (!values.isSuccess) {
throw new Error("Valores de perfil persistidos inconsistentes.");
}
return values.data;
}
export class WorkProfilePersistenceMapper {
toDomain(row: ProfileRow, revisions: readonly ProfileRevisionRow[]) {
const values = valuesOf(row.values);
const ordered = [...revisions].sort((a, b) => a.position - b.position);
const history = ordered.map((revision, position) => {
const revisionValues = valuesOf(revision.values);
if (
revision.position !== position ||
!z.iso.datetime().safeParse(revision.recordedAt).success ||
revision.recordedAt < (ordered[position - 1]?.recordedAt ?? "") ||
!values.sameTarget(revisionValues) ||
revision.accountId !== revisionValues.toPrimitive().accountId
) {
throw new Error("Historial de perfil persistido inconsistente.");
}
return WorkProfileRevision.rehydrate(
{
values: revisionValues,
actorId: profileIdentity(revision.actorId),
recordedAt: revision.recordedAt,
},
profileIdentity(revision.id)
);
});
if (
!history.at(-1)?.values.equals(values) ||
row.personId !== values.personId.toPrimitive() ||
row.accountId !== values.toPrimitive().accountId ||
row.assetId !== values.assetId.toPrimitive() ||
row.currentKey !== (values.active ? [row.personId, row.assetId].join(":") : null)
) {
throw new Error("Referencias de perfil persistidas inconsistentes.");
}
return WorkProfile.rehydrate(
{ companyId: profileIdentity(row.companyId), values, history },
profileIdentity(row.id)
);
}
toPersistence(item: WorkProfile) {
const values = item.values.toPrimitive();
return {
root: {
id: item.id.toPrimitive(),
companyId: item.companyId.toPrimitive(),
personId: values.personId,
accountId: values.accountId,
assetId: values.assetId,
values: JSON.stringify(values),
currentKey: item.values.active ? [values.personId, values.assetId].join(":") : null,
},
revisions: item.history.map((revision, position) => ({
id: revision.id.toPrimitive(),
profileId: item.id.toPrimitive(),
accountId: revision.values.toPrimitive().accountId,
position,
actorId: revision.actorId.toPrimitive(),
recordedAt: revision.recordedAt,
values: JSON.stringify(revision.values.toPrimitive()),
})),
};
}
}

View File

@ -6,9 +6,12 @@ import { CompanyUseCases } from "./application/company.use-cases";
import { CompanyInputMapper } from "./application/company-input.mapper";
import type { IMailInventoryService } from "./application/mail-configuration.repository";
import { MailConfigurationUseCases } from "./application/mail-configuration.use-cases";
import type { IWorkProfileInventoryService } from "./application/work-profile.repository";
import { WorkProfileUseCases } from "./application/work-profile.use-cases";
import { registerAccessRoutes } from "./infrastructure/http/access.routes";
import { registerCompanyRoutes } from "./infrastructure/http/company.routes";
import { registerMailRoutes } from "./infrastructure/http/mail-configuration.routes";
import { registerWorkProfileRoutes } from "./infrastructure/http/work-profile.routes";
import {
AccessPersistenceMapper,
SequelizeAccessRepository,
@ -22,6 +25,8 @@ import { migratePeople } from "./infrastructure/persistence/migrate-people";
import { initializePeopleModels } from "./infrastructure/persistence/people.model";
import { SequelizeCompanyRepository } from "./infrastructure/persistence/sequelize-company.repository";
import { SequelizeMailConfigurationRepository } from "./infrastructure/persistence/sequelize-mail-configuration.repository";
import { SequelizeWorkProfileRepository } from "./infrastructure/persistence/sequelize-work-profile.repository";
import { migrateWorkProfiles } from "./infrastructure/persistence/work-profile-migration";
export async function createCompanyModule(database: Sequelize, authenticate: RequestHandler) {
await migrateCompanies(database);
@ -35,6 +40,16 @@ export async function createCompanyModule(database: Sequelize, authenticate: Req
);
const useCases = new CompanyUseCases(repository, new CompanyInputMapper());
return {
initializeProfiles: async (inventory: IWorkProfileInventoryService) => {
await migrateWorkProfiles(database);
const profiles = new WorkProfileUseCases(
new SequelizeWorkProfileRepository(database, inventory)
);
return {
register: (app: Parameters<typeof registerWorkProfileRoutes>[0]) =>
registerWorkProfileRoutes(app, profiles, authenticate),
};
},
initializeMail: async (inventory: IMailInventoryService) => {
await migrateMailConfigurations(database);
const mail = new MailConfigurationUseCases(

View File

@ -6,3 +6,4 @@ export * from "./dto/shared/access.dto";
export * from "./dto/shared/company.dto";
export * from "./dto/shared/people.dto";
export * from "./mail-configurations";
export * from "./work-profiles";

View File

@ -0,0 +1,30 @@
import { z } from "zod/v4";
import { WorkProfileInputSchema } from "../api/domain/work-profile-values";
export type { WorkProfileInput } from "../api/domain/work-profile-values";
export { WorkProfileInputSchema };
export const ProfileParamsSchema = z.strictObject({ companyId: z.uuid() });
export const ProfileItemParamsSchema = ProfileParamsSchema.extend({ id: z.uuid() });
export const SaveWorkProfileRequestSchema = z.strictObject({ values: WorkProfileInputSchema });
export const WorkProfileSchema = z.strictObject({
id: z.uuid(),
companyId: z.uuid(),
values: WorkProfileInputSchema,
needsReview: z.boolean(),
history: z
.array(
z.strictObject({
id: z.uuid(),
values: WorkProfileInputSchema,
actorId: z.uuid(),
actorName: z.string().min(1),
recordedAt: z.iso.datetime(),
})
)
.min(1),
});
export const WorkProfilesResponseSchema = z.strictObject({ profiles: z.array(WorkProfileSchema) });
export const WorkProfileResponseSchema = z.strictObject({ profile: WorkProfileSchema });
export type WorkProfileDTO = z.infer<typeof WorkProfileSchema>;

View File

@ -20,6 +20,7 @@ import type { CompanyDTO } from "../../common";
import { AccessWorkspacePage } from "../access/access-workspace";
import { companyApi, companyKeys } from "../api/company-api";
import { MailConfigurationsPage } from "../mail/mail-configurations-page";
import { WorkProfilesPage } from "../profiles/work-profiles-page";
import { CompanyFeedback } from "./company-feedback";
import { CompanyForm } from "./company-form";
@ -109,7 +110,8 @@ type CompanyTab =
| "assets"
| "inventory"
| "workstations"
| "mail";
| "mail"
| "profiles";
export function CompanyDetail({
id,
@ -198,6 +200,7 @@ export function CompanyDetail({
[
["inventory", assetTranslation("inventory"), BoxIcon],
["workstations", assetTranslation("workstations"), BriefcaseIcon],
["profiles", t("profiles.title"), UsersIcon],
["people", `${t("people")} (${company.people.length})`, UsersIcon],
[
"departments",
@ -252,6 +255,8 @@ function CompanySection({
return (
<AccessWorkspacePage csrfToken={csrfToken} people={company.people} scope={company.id} />
);
case "profiles":
return <WorkProfilesPage company={company} csrfToken={csrfToken} />;
case "mail":
return <MailConfigurationsPage company={company} csrfToken={csrfToken} />;
case "workstations":

View File

@ -5,12 +5,10 @@ import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import type { AccessResponseDTO, CompanyDTO } from "../../common";
import type { MailConfigurationInput } from "../../common/mail-configurations";
import { AccessForm } from "../access/access-form";
import { PersonForm } from "../components/person-form";
import type { QuickKind } from "./mail-configuration-form";
import { MailFeedback } from "./mail-feedback";
import { MailFeedback } from "../mail/mail-feedback";
export type ConfigurationQuickKind = "person" | "account" | "asset" | "authorization";
const titles = {
person: "newPerson",
@ -18,7 +16,7 @@ const titles = {
asset: "newAsset",
authorization: "authorize",
} as const;
export function MailQuickEditor({
export function ConfigurationQuickEditor({
kind,
company,
csrfToken,
@ -27,10 +25,10 @@ export function MailQuickEditor({
onCreated,
onClose,
}: {
kind: QuickKind;
kind: ConfigurationQuickKind;
company: CompanyDTO;
csrfToken: string;
values: MailConfigurationInput;
values: Readonly<{ personId: string; accountId: string }>;
data: AccessResponseDTO | undefined;
onCreated: (field: "personId" | "accountId" | "assetId", id?: string) => void;
onClose: () => void;

View File

@ -1,7 +1,9 @@
import { mailEnglish, mailSpanish } from "./mail/mail-i18n";
import { profilesEnglish, profilesSpanish } from "./profiles/profile-i18n";
export const companiesSpanish = {
mail: mailSpanish,
profiles: profilesSpanish,
companySpaces: "Espacios de compañías",
globalCatalogs: "Catálogos globales",
supportWorkspace: "Tu espacio de soporte",
@ -117,6 +119,7 @@ export const companiesSpanish = {
};
export const companiesEnglish = {
mail: mailEnglish,
profiles: profilesEnglish,
companySpaces: "Company spaces",
globalCatalogs: "Global catalogs",
supportWorkspace: "Your support workspace",

View File

@ -21,12 +21,14 @@ import {
} from "../../common/mail-configurations";
import { accessApi, accessKeys } from "../access/access-api";
import { accessLabel } from "../access/access-labels";
import {
ConfigurationQuickEditor,
type ConfigurationQuickKind,
} from "../components/configuration-quick-editor";
import { mailApi, mailKeys } from "./mail-api";
import { MailFeedback } from "./mail-feedback";
import { MailQuickEditor } from "./mail-quick-editor";
export type QuickKind = "person" | "account" | "asset" | "authorization";
export function MailConfigurationForm({
company,
csrfToken,
@ -41,7 +43,7 @@ export function MailConfigurationForm({
const { t } = useTranslation("companies");
const client = useQueryClient();
const prefix = useId();
const [quick, setQuick] = useState<QuickKind>();
const [quick, setQuick] = useState<ConfigurationQuickKind>();
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
@ -254,7 +256,7 @@ export function MailConfigurationForm({
</FormActions>
</form>
{quick && (
<MailQuickEditor
<ConfigurationQuickEditor
company={company}
csrfToken={csrfToken}
data={access.data}

View File

@ -0,0 +1,11 @@
import { useTranslation } from "react-i18next";
import { WorkProfileApiError } from "./work-profile-api";
export function ProfileFeedback({ error }: { error: unknown }) {
const { t } = useTranslation("companies");
return error ? (
<p className="text-sm text-destructive" role="alert">
{t(`profiles.errors.${error instanceof WorkProfileApiError ? error.code : "INTERNAL_ERROR"}`)}
</p>
) : null;
}

View File

@ -0,0 +1,96 @@
export const profilesSpanish = {
title: "Perfiles de trabajo",
new: "Nuevo perfil",
edit: "Editar perfil",
name: "Nombre del perfil",
person: "Persona",
asset: "Dispositivo",
account: "Cuenta de inicio de sesión (opcional)",
noAccount: "Sin cuenta registrada",
active: "Perfil vigente",
inactive: "Perfil archivado",
choose: "Selecciona un elemento",
notes: "Notas (opcional)",
search: "Buscar por perfil, persona o dispositivo",
empty: "Esta compañía todavía no tiene perfiles de trabajo.",
history: "Ficha e historial",
showArchived: "Mostrar también los perfiles archivados",
review:
"Revisar: la persona, el dispositivo o la cuenta ya no están disponibles para este perfil.",
fixed:
"La persona y el dispositivo identifican este perfil. Para otro dispositivo o persona, crea un perfil nuevo.",
help: "¿Para qué sirve un perfil de trabajo?",
description:
"Identifica el entorno de una persona en un dispositivo concreto. Una persona puede tener varios perfiles y un equipo puede tener perfiles de varias personas.",
guidance:
"Elige un ordenador u otro dispositivo que aloje la configuración de la persona. La cuenta de inicio de sesión es opcional y, si se indica, debe estar autorizada para ella. Un perfil vigente no significa que todas sus configuraciones estén listas.",
examplesLabel: "Ejemplos y recomendaciones",
examples:
"Ana en PORT-ANA-01; Ana en su segundo ordenador; Luis en un ordenador compartido. Los correos, programas y recursos se incorporarán en las siguientes entregas.",
scope:
"Todos los elementos pertenecen a esta compañía. Archivar conserva los datos y el historial; no retira accesos ni borra configuraciones en el equipo.",
unknownConfiguration: "Configuraciones del perfil: todavía no registradas.",
stations: "Puestos que utilizan este dispositivo",
noStation: "Este dispositivo no está incluido en un puesto vigente.",
accountHint:
"Seleccionar la cuenta no la configura en el dispositivo. Su autorización y su configuración se gestionan por separado.",
errors: {
INVALID_REQUEST: "Revisa los campos del formulario.",
PROFILE_REFERENCE_NOT_FOUND:
"La persona, el dispositivo o la cuenta no pertenecen a esta compañía.",
PROFILE_REFERENCE_UNAVAILABLE:
"La persona o el dispositivo no están disponibles, o la cuenta no tiene una autorización vigente para esta persona.",
PROFILE_ALREADY_ACTIVE: "Ya existe un perfil vigente de esta persona en este dispositivo.",
PROFILE_TARGET_IMMUTABLE: "La persona y el dispositivo de un perfil no pueden cambiar.",
PROFILE_NOT_FOUND: "El perfil ya no está disponible.",
COMPANY_NOT_FOUND: "La compañía ya no está disponible.",
INTERNAL_ERROR: "No se pudo completar la operación. Inténtalo de nuevo.",
},
};
export const profilesEnglish = {
title: "Work profiles",
new: "New profile",
edit: "Edit profile",
name: "Profile name",
person: "Person",
asset: "Device",
account: "Sign-in account (optional)",
noAccount: "No recorded account",
active: "Current profile",
inactive: "Archived profile",
choose: "Choose an item",
notes: "Notes (optional)",
search: "Search by profile, person or device",
empty: "This company has no work profiles yet.",
history: "Details and history",
showArchived: "Include archived profiles",
review: "Review: the person, device or account is no longer available for this profile.",
fixed:
"Person and device identify this profile. Create another profile for a different person or device.",
help: "What is a work profile for?",
description:
"Identifies a person's environment on a specific device. A person can have several profiles and a device can host profiles for several people.",
guidance:
"Choose a computer or another device hosting the person's configuration. The sign-in account is optional and must be authorized for this person when supplied. A current profile does not mean all its configurations are ready.",
examplesLabel: "Examples and guidance",
examples:
"Ana on PORT-ANA-01; Ana on a second computer; Luis on a shared computer. Email, applications and resources will be added in later deliveries.",
scope:
"All elements belong to this company. Archiving preserves data and history; it does not revoke access or remove device configurations.",
unknownConfiguration: "Profile configurations: not recorded yet.",
stations: "Workstations using this device",
noStation: "This device is not included in a current workstation.",
accountHint:
"Selecting an account does not configure it on the device. Authorization and configuration are tracked separately.",
errors: {
INVALID_REQUEST: "Check the form fields.",
PROFILE_REFERENCE_NOT_FOUND: "The person, device or account does not belong to this company.",
PROFILE_REFERENCE_UNAVAILABLE:
"The person or device is unavailable, or the account has no current authorization for this person.",
PROFILE_ALREADY_ACTIVE: "A current profile already exists for this person on this device.",
PROFILE_TARGET_IMMUTABLE: "A profile's person and device cannot change.",
PROFILE_NOT_FOUND: "This profile is unavailable.",
COMPANY_NOT_FOUND: "This company is unavailable.",
INTERNAL_ERROR: "The operation could not be completed. Please try again.",
},
};

View File

@ -0,0 +1,75 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { inventoryKeys } from "@metis/assets/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createInstance } from "i18next";
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nextProvider } from "react-i18next";
import { accessKeys } from "../access/access-api";
import { companiesEnglish, companiesSpanish } from "../i18n";
import { profileKeys } from "./work-profile-api";
import { WorkProfileForm } from "./work-profile-form";
import { WorkProfilesPage } from "./work-profiles-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(accessKeys.scope(company.id), { items: [], reviews: [], coverage: [] });
client.setQueryData(inventoryKeys.company(company.id), {
assets: [],
relationships: [],
workstations: [],
});
try {
return renderToStaticMarkup(
<I18nextProvider i18n={i18n}>
<QueryClientProvider client={client}>{node}</QueryClientProvider>
</I18nextProvider>
);
} finally {
client.clear();
}
}
test("perfiles: listado es/en, alta en cabecera y ausencia de formulario junto al listado", async () => {
for (const language of ["es", "en"]) {
const html = await render(language, <WorkProfilesPage company={company} csrfToken="test" />);
assert.equal(html.includes("<form"), false);
assert.ok(html.includes(language === "es" ? "Nuevo perfil" : "New profile"));
assert.ok(html.includes(language === "es" ? "perfiles archivados" : "archived profiles"));
}
});
test("perfiles: cuenta opcional, altas rápidas y ayuda anterior al pie", async () => {
const html = await render(
"es",
<WorkProfileForm company={company} csrfToken="test" onSaved={() => undefined} />
);
for (const label of ["Nueva persona", "Nueva cuenta", "Nuevo dispositivo"]) {
assert.ok(html.includes(`aria-label="${label}"`));
}
assert.ok(html.includes("Cuenta de inicio de sesión (opcional)"));
assert.ok(html.includes("Un perfil vigente no significa"));
assert.ok(html.indexOf("¿Para qué sirve un perfil de trabajo?") < html.indexOf("Guardar"));
assert.equal(html.includes('type="password"'), false);
});

View File

@ -0,0 +1,35 @@
import { z } from "zod/v4";
import {
type WorkProfileInput,
WorkProfileResponseSchema,
WorkProfilesResponseSchema,
} from "../../common/work-profiles";
export const profileKeys = { company: (id: string) => ["work-profiles", id] };
export class WorkProfileApiError extends Error {
constructor(public readonly code: string) {
super(code);
}
}
async function request(companyId: string, values?: WorkProfileInput, token?: string, id?: string) {
const response = await fetch(`/api/work-profiles/${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 json: unknown = await response.json();
if (!response.ok) {
const parsed = z.object({ code: z.string() }).safeParse(json);
throw new WorkProfileApiError(parsed.success ? parsed.data.code : "INTERNAL_ERROR");
}
return json;
}
export const profileApi = {
async get(companyId: string) {
return WorkProfilesResponseSchema.parse(await request(companyId));
},
async save(companyId: string, values: WorkProfileInput, token: string, id?: string) {
return WorkProfileResponseSchema.parse(await request(companyId, values, token, id));
},
};

View File

@ -0,0 +1,241 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
import { Button } from "@repo/shadcn-ui/components/button";
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
import { Input } from "@repo/shadcn-ui/components/input";
import { Label } from "@repo/shadcn-ui/components/label";
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
import { Textarea } from "@repo/shadcn-ui/components/textarea";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useId, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { CompanyDTO } from "../../common";
import {
type WorkProfileDTO,
type WorkProfileInput,
WorkProfileInputSchema,
} from "../../common/work-profiles";
import { accessApi, accessKeys } from "../access/access-api";
import { accessLabel } from "../access/access-labels";
import {
ConfigurationQuickEditor,
type ConfigurationQuickKind,
} from "../components/configuration-quick-editor";
import { ProfileFeedback } from "./profile-feedback";
import { profileApi, profileKeys } from "./work-profile-api";
export function WorkProfileForm({
company,
csrfToken,
profile,
onSaved,
}: {
company: CompanyDTO;
csrfToken: string;
profile?: WorkProfileDTO | undefined;
onSaved: () => void;
}) {
const { t } = useTranslation("companies");
const client = useQueryClient();
const prefix = useId();
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const [quick, setQuick] = useState<ConfigurationQuickKind>();
const access = useQuery({
queryKey: accessKeys.scope(company.id),
queryFn: () => accessApi.request(company.id),
});
const inventory = useQuery({
queryKey: inventoryKeys.company(company.id),
queryFn: () => inventoryApi.get(company.id),
});
const form = useForm<WorkProfileInput>({
resolver: zodResolver(WorkProfileInputSchema),
defaultValues: profile?.values ?? {
name: "",
personId: "",
assetId: "",
accountId: null,
active: true,
notes: null,
},
});
const values = form.watch();
const items = access.data?.items ?? [];
const today = new Date().toISOString().slice(0, 10);
const authorized =
!values.accountId ||
items.some((item) => {
const link = item.values;
return (
link.kind === "account_person" &&
link.personId === values.personId &&
link.accountId === values.accountId &&
link.startDate <= today &&
(link.endDate === null || today < link.endDate) &&
(link.configuration === "pending" || link.configuration === "configured")
);
});
const mutation = useMutation({
mutationFn: (input: WorkProfileInput) =>
profileApi.save(company.id, input, csrfToken, profile?.id),
onSuccess: async () => {
await client.invalidateQueries({ queryKey: profileKeys.company(company.id) });
if (mounted.current) {
onSaved();
}
},
});
const disabled = mutation.isPending;
const selectors = [
{
field: "personId",
label: "person",
create: "newPerson",
kind: "person",
options: company.people
.filter((item) => item.active || item.id === values.personId)
.map((item) => ({ value: item.id, label: item.name })),
},
{
field: "assetId",
label: "asset",
create: "newAsset",
kind: "asset",
options: (inventory.data?.assets ?? [])
.filter((item) => item.values.status !== "retired" || item.id === values.assetId)
.map((item) => ({ value: item.id, label: item.values.name })),
},
{
field: "accountId",
label: "account",
create: "newAccount",
kind: "account",
options: items
.filter(
(item) =>
item.values.kind === "account" &&
((item.values.active && item.values.usage !== "technical") ||
item.id === values.accountId)
)
.map((item) => ({ value: item.id, label: accessLabel(item, items, company.people) })),
},
] as const;
return (
<>
<form className="space-y-5" onSubmit={form.handleSubmit((input) => mutation.mutate(input))}>
<fieldset className="space-y-4" disabled={disabled}>
<div className="space-y-2">
<Label htmlFor={prefix}>{t("profiles.name")}</Label>
<Input id={prefix} maxLength={100} required {...form.register("name")} />
</div>
<div className="grid gap-4 sm:grid-cols-2">
{selectors.map((selector) => {
const fixed = !!profile && selector.field !== "accountId";
return (
<div className="space-y-2" key={selector.field}>
<Label htmlFor={`${prefix}-${selector.field}`}>
{t(`profiles.${selector.label}`)}
</Label>
<SelectWithAction
action={
fixed
? undefined
: {
label: t(`mail.${selector.create}`),
onClick: () => setQuick(selector.kind),
}
}
disabled={disabled || fixed || !inventory.data || !access.data}
id={`${prefix}-${selector.field}`}
label={t(`profiles.${selector.label}`)}
onValueChange={(id) =>
form.setValue(
selector.field,
selector.field === "accountId" ? id || null : id
)
}
options={selector.options}
placeholder={t(
selector.field === "accountId" ? "profiles.noAccount" : "profiles.choose"
)}
required={selector.field !== "accountId"}
value={values[selector.field] ?? ""}
/>
</div>
);
})}
</div>
<p className="text-sm text-muted-foreground">{t("profiles.accountHint")}</p>
{values.personId && values.accountId && !authorized && (
<div className="space-y-2 rounded-md border p-3 text-sm">
<p>{t("mail.noAuthorization")}</p>
<Button onClick={() => setQuick("authorization")} type="button" variant="outline">
{t("mail.authorize")}
</Button>
</div>
)}
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" {...form.register("active")} />
{t("profiles.active")}
</label>
<div className="space-y-2">
<Label htmlFor={`${prefix}-notes`}>{t("profiles.notes")}</Label>
<Textarea
id={`${prefix}-notes`}
maxLength={4000}
onChange={(event) => form.setValue("notes", event.target.value || null)}
rows={3}
value={values.notes ?? ""}
/>
</div>
</fieldset>
{profile && <p className="text-sm text-muted-foreground">{t("profiles.fixed")}</p>}
{Object.keys(form.formState.errors).length > 0 && (
<p className="text-destructive" role="alert">
{t("profiles.errors.INVALID_REQUEST")}
</p>
)}
<ProfileFeedback error={mutation.error ?? access.error ?? inventory.error} />
<ContextHelp
description={t("profiles.description")}
examples={t("profiles.examples")}
examplesLabel={t("profiles.examplesLabel")}
guidance={t("profiles.guidance")}
scopeNote={t("profiles.scope")}
title={t("profiles.help")}
/>
<FormActions>
<Button disabled={disabled || !inventory.data || !access.data} type="submit">
{t(disabled ? "saving" : "save")}
</Button>
</FormActions>
</form>
{quick && (
<ConfigurationQuickEditor
company={company}
csrfToken={csrfToken}
data={access.data}
kind={quick}
onClose={() => setQuick(undefined)}
onCreated={(field, id) => {
if (mounted.current && id) {
form.setValue(field, id);
setQuick(undefined);
}
}}
values={{ personId: values.personId, accountId: values.accountId ?? "" }}
/>
)}
</>
);
}

View File

@ -0,0 +1,219 @@
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
import { Button } from "@repo/shadcn-ui/components/button";
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { Input } from "@repo/shadcn-ui/components/input";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { CompanyDTO } from "../../common";
import { accessApi, accessKeys } from "../access/access-api";
import { accessLabel } from "../access/access-labels";
import { ProfileFeedback } from "./profile-feedback";
import { profileApi, profileKeys } from "./work-profile-api";
import { WorkProfileForm } from "./work-profile-form";
export function WorkProfilesPage({
company,
csrfToken,
}: {
company: CompanyDTO;
csrfToken: string;
}) {
const { t, i18n } = useTranslation("companies");
const [editor, setEditor] = useState<string>();
const [view, setView] = useState<string>();
const [search, setSearch] = useState("");
const [archived, setArchived] = useState(false);
const query = 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 access = useQuery({
queryKey: accessKeys.scope(company.id),
queryFn: () => accessApi.request(company.id),
});
const profiles = query.data?.profiles ?? [];
const edited = profiles.find((item) => item.id === editor);
const viewed = profiles.find((item) => item.id === view);
const person = (id: string) => company.people.find((item) => item.id === id)?.name ?? id;
const asset = (id: string) =>
inventory.data?.assets.find((item) => item.id === id)?.values.name ?? id;
const account = (id: string | null) => {
const item = access.data?.items.find((item) => item.id === id);
return item
? accessLabel(item, access.data?.items ?? [], company.people)
: (id ?? t("profiles.noAccount"));
};
const filtered = profiles.filter(
(item) =>
(archived || item.values.active) &&
[item.values.name, person(item.values.personId), asset(item.values.assetId)].some((value) =>
value.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())
)
);
const ready = !!(query.data && inventory.data && access.data);
return (
<div className="space-y-5">
<header className="flex flex-wrap items-center justify-between gap-4">
<h2 className="text-xl font-semibold">{t("profiles.title")}</h2>
<Button disabled={!ready} onClick={() => setEditor("new")}>
{t("profiles.new")}
</Button>
</header>
<Input
aria-label={t("profiles.search")}
onChange={(event) => setSearch(event.target.value)}
placeholder={t("profiles.search")}
value={search}
/>
<label className="flex items-center gap-2 text-sm">
<input
checked={archived}
onChange={(event) => setArchived(event.target.checked)}
type="checkbox"
/>
{t("profiles.showArchived")}
</label>
{(query.isPending || inventory.isPending || access.isPending) && (
<p role="status">{t("loading")}</p>
)}
<ProfileFeedback error={query.error ?? inventory.error ?? access.error} />
{(query.isError || inventory.isError || access.isError) && (
<Button
onClick={() => {
Promise.all([query.refetch(), inventory.refetch(), access.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(profiles.length ? "noResults" : "profiles.empty")}
</p>
)}
{filtered.map((item) => (
<div className="flex flex-wrap items-center justify-between gap-4 py-4" key={item.id}>
<div className="min-w-0 break-words">
<h3 className="font-medium">
{item.values.name} ·{" "}
{t(item.values.active ? "profiles.active" : "profiles.inactive")}
</h3>
<p className="text-sm text-muted-foreground">
{person(item.values.personId)} · {asset(item.values.assetId)}
</p>
<p className="mt-1 text-sm">{account(item.values.accountId)}</p>
{item.needsReview && (
<p className="mt-2 text-sm text-amber-700">{t("profiles.review")}</p>
)}
</div>
<div className="flex gap-2">
<Button onClick={() => setView(item.id)} size="sm" variant="outline">
{t("profiles.history")}
</Button>
<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 ? "profiles.edit" : "profiles.new")}
wide
>
<WorkProfileForm
company={company}
csrfToken={csrfToken}
onSaved={() => setEditor(undefined)}
profile={edited}
/>
</FormDialog>
)}
{viewed && (
<FormDialog
cancelLabel={t("close")}
onOpenChange={(open) => {
if (!open) {
setView(undefined);
}
}}
open
title={t("profiles.history")}
wide
>
<div className="space-y-4">
<p className="font-medium">
{person(viewed.values.personId)} · {asset(viewed.values.assetId)}
</p>
<p>
{t("profiles.account")}: {account(viewed.values.accountId)}
</p>
{viewed.needsReview && <p className="text-amber-700">{t("profiles.review")}</p>}
<p className="text-sm text-muted-foreground">{t("profiles.unknownConfiguration")}</p>
<div>
<h3 className="font-medium">{t("profiles.stations")}</h3>
<p className="text-sm">
{inventory.data?.workstations
.filter(
(station) =>
station.values.active &&
[...station.values.assetIds, ...station.values.sharedAssetIds].includes(
viewed.values.assetId
)
)
.map((station) => station.values.name)
.join(" · ") || t("profiles.noStation")}
</p>
</div>
<ol className="space-y-3">
{[...viewed.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>
{revision.values.name} ·{" "}
{t(revision.values.active ? "profiles.active" : "profiles.inactive")}
</p>
<p>{account(revision.values.accountId)}</p>
{revision.values.notes && (
<p className="mt-2 whitespace-pre-wrap">{revision.values.notes}</p>
)}
</li>
))}
</ol>
</div>
</FormDialog>
)}
</div>
);
}