diff --git a/apps/server/package.json b/apps/server/package.json index b18f994..c952129 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -20,7 +20,8 @@ "mariadb": "3.5.4", "express-session": "1.19.0", "connect-session-sequelize": "8.0.6", - "@metis/companies": "workspace:*" + "@metis/companies": "workspace:*", + "@metis/assets": "workspace:*" }, "devDependencies": { "@repo/typescript-config": "workspace:*", diff --git a/apps/server/src/assets.integration.ts b/apps/server/src/assets.integration.ts new file mode 100644 index 0000000..160b9f7 --- /dev/null +++ b/apps/server/src/assets.integration.ts @@ -0,0 +1,211 @@ +import assert from "node:assert/strict"; + +import { CatalogResponseSchema, SaveDefinitionResponseSchema } from "@metis/assets/common"; +import { createAssetModule } from "@metis/assets/module"; +import type { Sequelize } from "sequelize"; +import { z } from "zod/v4"; +export async function verifyAssets( + request: (path: string, body?: unknown, origin?: string, token?: string) => Promise, + companyId: string, + otherId: string, + database: Sequelize +) { + const global = "/asset-catalog/global"; + const company = `/asset-catalog/${companyId}`; + const other = `/asset-catalog/${otherId}`; + async function read(path: string) { + const response = await request(path); + assert.equal(response.status, 200); + return CatalogResponseSchema.parse(await response.json()); + } + async function save(path: string, values: unknown) { + const response = await request(path, { values }); + assert.ok([200, 201].includes(response.status), await response.clone().text()); + return SaveDefinitionResponseSchema.parse(await response.json()); + } + async function error(path: string, body: unknown, code: string, status = 409) { + const response = await request(path, body); + assert.equal(response.status, status); + const data = z.object({ code: z.string() }).parse(await response.json()); + assert.equal(data.code, code); + } + const initial = await read(global); + assert.equal(initial.items.length, 42); + assert.equal(initial.items.filter((item) => item.values.kind === "template").length, 12); + assert.ok(initial.items.every((item) => item.builtin && item.id.charAt(14) === "7")); + assert.ok( + initial.items.every((item) => + item.bindings.every((binding) => !binding.required && binding.id.charAt(14) === "7") + ) + ); + const stock = initial.items.find((item) => item.values.name === "Procesador"); + assert.ok(stock); + await error( + `${global}/${stock.id}/update`, + { values: { ...stock.values, name: "Renombrado" } }, + "BUILTIN_PROTECTED" + ); + assert.equal( + (await request(`${global}/${stock.id}/status`, { active: false }, "https://other.example")) + .status, + 403 + ); + assert.equal( + (await request(`${global}/${stock.id}/status`, { active: false }, undefined, "a".repeat(64))) + .status, + 403 + ); + assert.equal((await request(`${global}/${stock.id}/status`, { active: false })).status, 200); + await createAssetModule(database, (_request, _response, next) => next()); + const restarted = await read(global); + assert.equal(restarted.items.find((item) => item.id === stock.id)?.effectiveActive, false); + assert.deepEqual( + restarted.items.map((item) => item.id).sort(), + initial.items.map((item) => item.id).sort() + ); + assert.equal((await request(`${global}/${stock.id}/status`, { active: true })).status, 200); + assert.equal((await request(`${company}/${stock.id}/status`, { active: false })).status, 200); + assert.equal( + (await read(company)).items.find((item) => item.id === stock.id)?.effectiveActive, + false + ); + assert.equal( + (await read(other)).items.find((item) => item.id === stock.id)?.effectiveActive, + true + ); + assert.equal((await read(global)).items.find((item) => item.id === stock.id)?.active, true); + assert.equal((await request(`${company}/${stock.id}/status`, { active: null })).status, 200); + assert.equal((await read(company)).items.find((item) => item.id === stock.id)?.overridden, false); + const localValues = { + kind: "field", + name: "Código interno", + type: "text", + description: "", + unit: "", + choices: [], + }; + const local = await save(company, localValues); + const field = local.items.find((item) => item.id === local.savedId); + assert.ok(field); + assert.equal(field.companyId, companyId); + assert.equal(field.builtin, false); + assert.ok(!(await read(other)).items.some((item) => item.id === field.id)); + assert.ok(!(await read(global)).items.some((item) => item.id === field.id)); + await error(company, { values: { ...localValues, name: " código INTERNO " } }, "NAME_EXISTS"); + await error(`${other}/${field.id}/update`, { values: localValues }, "DEFINITION_NOT_FOUND", 404); + await error( + `${company}/${field.id}/update`, + { values: { ...localValues, type: "number" } }, + "FIELD_TYPE_FIXED" + ); + const templateValues = { + kind: "template", + name: "Equipo especial", + description: "", + fields: [ + { fieldId: field.id, required: false }, + { fieldId: stock.id, required: false }, + ], + }; + await error(global, { values: templateValues }, "FIELD_UNAVAILABLE"); + await error(other, { values: templateValues }, "FIELD_UNAVAILABLE"); + const template = await save(company, templateValues); + const saved = template.items.find((item) => item.id === template.savedId); + assert.ok(saved); + const edited = await save(`${company}/${saved.id}/update`, { + ...templateValues, + name: "Equipo especial actualizado", + }); + assert.equal(edited.savedId, saved.id); + assert.deepEqual(edited.items.find((item) => item.id === saved.id)?.bindings, saved.bindings); + await error( + company, + { + values: { + ...templateValues, + name: "Duplicado", + fields: [templateValues.fields[0], templateValues.fields[0]], + }, + }, + "INVALID_FIELDS" + ); + const status = await request(`${company}/${field.id}/status`, { active: false }); + assert.equal(status.status, 200); + await error( + company, + { values: { ...templateValues, name: "Campo inactivo" } }, + "FIELD_UNAVAILABLE" + ); + assert.ok((await read(company)).items.some((item) => item.id === saved.id)); + await save(`${company}/${saved.id}/update`, { + ...templateValues, + name: "Equipo especial actualizado", + }); + const list = { + kind: "field", + name: "Estado batería", + type: "choice", + unit: "", + description: "", + choices: ["Buena", "Sustituir"], + }; + const custom = await save(global, list); + await error( + `${global}/${custom.savedId}/update`, + { values: { ...list, choices: ["Buena"] } }, + "FIELD_TYPE_FIXED" + ); + await save(`${global}/${custom.savedId}/update`, { + ...list, + choices: [...list.choices, "Desconocida"], + }); + await error(global, { values: { ...list, name: "Sin valores", choices: [] } }, "INVALID_CHOICES"); + const monitor = initial.items.find((item) => item.values.name === "Monitor"); + assert.ok(monitor && monitor.values.kind === "template"); + const binding = monitor.bindings[0]; + assert.ok(binding); + try { + await database.query("UPDATE asset_definitions SET `values`=:values WHERE id=:id", { + replacements: { + id: monitor.id, + values: JSON.stringify({ + ...monitor.values, + fields: monitor.values.fields.map((entry, index) => + index === 0 ? { ...entry, fieldId: field.id } : entry + ), + }), + }, + }); + await database.query("UPDATE asset_template_fields SET fieldId=:fieldId WHERE id=:id", { + replacements: { fieldId: field.id, id: binding.id }, + }); + const corrupt = await request(global); + assert.equal(corrupt.status, 500); + const hidden = z.object({ code: z.string() }).parse(await corrupt.json()); + assert.equal(hidden.code, "INTERNAL_ERROR"); + } finally { + await database.query("UPDATE asset_template_fields SET fieldId=:fieldId WHERE id=:id", { + replacements: { fieldId: binding.fieldId, id: binding.id }, + }); + await database.query("UPDATE asset_definitions SET `values`=:values WHERE id=:id", { + replacements: { values: JSON.stringify(monitor.values), id: monitor.id }, + }); + } + const concurrent = await Promise.all([ + request(company, { values: { ...localValues, name: "Concurrente" } }), + request(company, { values: { ...localValues, name: "Concurrente" } }), + ]); + assert.deepEqual(concurrent.map((response) => response.status).sort(), [201, 409]); + await error( + `/asset-catalog/${"82be74bb-894f-4cca-ac7b-dedc96c15689"}`, + undefined, + "COMPANY_NOT_FOUND", + 404 + ); + await error( + global, + { values: { ...localValues, name: "Injection", builtin: true } }, + "INVALID_REQUEST", + 400 + ); +} diff --git a/apps/server/src/config/database.ts b/apps/server/src/config/database.ts index 5da3b5a..eca5a75 100644 --- a/apps/server/src/config/database.ts +++ b/apps/server/src/config/database.ts @@ -11,6 +11,7 @@ const DatabaseEnvironmentSchema = z.object({ APP_ORIGIN: z.url(), NODE_ENV: z.enum(["development", "test", "production"]).default("development"), }); + export function readDatabaseEnvironment(source: NodeJS.ProcessEnv) { const parsed = DatabaseEnvironmentSchema.safeParse(source); if (!parsed.success) { @@ -18,12 +19,13 @@ export function readDatabaseEnvironment(source: NodeJS.ProcessEnv) { } return parsed.data; } + export function createDatabase(environment: ReturnType) { return new Sequelize(environment.DB_NAME, environment.DB_USER, environment.DB_PASSWORD, { dialect: "mariadb", host: environment.DB_HOST, port: environment.DB_PORT, - logging: false, + logging: true, pool: { max: 5, min: 0, idle: 10000 }, }); } diff --git a/apps/server/src/identity.test.ts b/apps/server/src/identity.test.ts index c2ddce3..822bb09 100644 --- a/apps/server/src/identity.test.ts +++ b/apps/server/src/identity.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { once } from "node:events"; import { test } from "node:test"; +import { createAssetModule } from "@metis/assets/module"; import { CompaniesResponseSchema, CompanyErrorResponseSchema, @@ -18,8 +19,10 @@ import { QueryTypes } from "sequelize"; import { verifyAccess } from "./access.integration"; import { createApp } from "./app"; +import { verifyAssets } from "./assets.integration"; import { createDatabase, readDatabaseEnvironment } from "./config/database"; import { createIdentityRuntime } from "./infrastructure/http/identity-runtime"; +import { verifyInventory } from "./inventory.integration"; test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección del administrador", async () => { const environment = readDatabaseEnvironment(process.env); @@ -32,6 +35,12 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de await once(server, "listening"); await database.query("UPDATE identity_state SET initialAdminId = NULL WHERE id = 1"); await database.query("DELETE FROM http_sessions"); + await database.query("DELETE FROM asset_revisions"); + await database.query("DELETE FROM assets"); + await database.query("DELETE FROM asset_availability"); + await database.query("DELETE FROM asset_template_fields"); + await database.query("DELETE FROM asset_definitions"); + await database.query("DELETE FROM asset_catalogs"); await database.query("DELETE FROM access_person_references"); await database.query("DELETE FROM access_references"); await database.query("DELETE FROM access_revisions"); @@ -44,6 +53,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de await database.query("DELETE FROM company_departments"); await database.query("DELETE FROM company_sites"); await database.query("DELETE FROM companies"); + await createAssetModule(database, (_request, _response, next) => next()); // Simular la tabla de contactos de la versión anterior solo en la base de pruebas. const legacyCompanyId = "48f13545-1529-4ba1-a353-a5f844141411"; const legacyPersonId = "4ec8f8c5-3ad9-4e22-abf3-b95370400898"; @@ -121,6 +131,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de assert.equal((await admin.session()).setupRequired, true); await competitor.session(); await expectError(await admin.request("/users"), 401, "UNAUTHENTICATED"); + await expectError(await admin.request("/asset-catalog/global"), 401, "UNAUTHENTICATED"); const first = { name: "Admin test", email: "ADMIN@example.test", @@ -670,6 +681,18 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de await (await operator.request(`/companies/${secondCompany.id}`)).json() ).company; await verifyAccess((...args) => operator.request(...args), accessCompany, accessOther); + await verifyAssets( + (...args) => operator.request(...args), + customer.id, + secondCompany.id, + database + ); + await verifyInventory( + (...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 } @@ -677,6 +700,7 @@ 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(`/assets/${customer.id}`), 401, "UNAUTHENTICATED"); assert.equal((await operator.session()).user, null); await expectError(await operator.request("/users"), 401, "UNAUTHENTICATED"); await expectError(await operator.request("/companies"), 401, "UNAUTHENTICATED"); diff --git a/apps/server/src/infrastructure/http/identity-runtime.ts b/apps/server/src/infrastructure/http/identity-runtime.ts index 1253cfc..c79837a 100644 --- a/apps/server/src/infrastructure/http/identity-runtime.ts +++ b/apps/server/src/infrastructure/http/identity-runtime.ts @@ -1,3 +1,4 @@ +import { createAssetModule } from "@metis/assets/module"; import { createCompanyModule } from "@metis/companies/module"; import { createIdentityModule, migrateIdentity } from "@metis/identity/module"; import connectSequelize from "connect-session-sequelize"; @@ -14,6 +15,7 @@ export async function createIdentityRuntime( await migrateIdentity(database); const identity = await createIdentityModule(database, environment.APP_ORIGIN); const companies = await createCompanyModule(database, identity.authenticate); + const assets = await createAssetModule(database, identity.authenticate); const SequelizeStore = connectSequelize(session.Store); const store = new SequelizeStore({ db: database, @@ -37,6 +39,7 @@ export async function createIdentityRuntime( register: (app: Parameters[0]) => { identity.register(app); companies.register(app); + assets.register(app); }, middleware, stop: () => store.stopExpiringSessions(), diff --git a/apps/server/src/inventory.integration.ts b/apps/server/src/inventory.integration.ts new file mode 100644 index 0000000..121842d --- /dev/null +++ b/apps/server/src/inventory.integration.ts @@ -0,0 +1,237 @@ +import assert from "node:assert/strict"; + +import { + AssetResponseSchema, + CatalogResponseSchema, + InventoryResponseSchema, + SaveDefinitionResponseSchema, +} from "@metis/assets/common"; +import { CompanyResponseSchema } from "@metis/companies/common"; +import type { Sequelize } from "sequelize"; +import { z } from "zod/v4"; +export async function verifyInventory( + request: (path: string, body?: unknown, origin?: string, token?: string) => Promise, + companyId: string, + otherId: string, + database: Sequelize +) { + const path = `/assets/${companyId}`; + async function save(url: string, values: unknown) { + const response = await request(url, { values }); + assert.ok([200, 201].includes(response.status), await response.clone().text()); + return AssetResponseSchema.parse(await response.json()).asset; + } + async function error(url: string, values: unknown, code: string, status = 409) { + const response = await request(url, { values }); + assert.equal(response.status, status); + assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code); + } + async function read(url = path) { + const response = await request(url); + assert.equal(response.status, 200); + return InventoryResponseSchema.parse(await response.json()); + } + const catalog = CatalogResponseSchema.parse( + await (await request(`/asset-catalog/${companyId}`)).json() + ); + const monitor = catalog.items.find((item) => item.values.name === "Monitor"); + assert.ok(monitor); + const laptop = catalog.items.find((item) => item.values.name === "Portátil"); + assert.ok(laptop); + const inch = catalog.items.find((item) => item.values.name === "Pulgadas"); + assert.ok(inch); + async function person(company: string) { + const response = await request(`/companies/${company}/people`, { + name: "Persona inventario", + position: null, + email: null, + phone: null, + active: true, + isContact: false, + }); + assert.equal(response.status, 201); + const saved = CompanyResponseSchema.parse(await response.json()).company.people.at(-1); + assert.ok(saved); + return saved; + } + async function site(company: string) { + const response = await request(`/companies/${company}/sites`, { + name: "Sede inventario", + address: null, + }); + assert.equal(response.status, 201); + const saved = CompanyResponseSchema.parse(await response.json()).company.sites.at(-1); + assert.ok(saved); + return saved; + } + const ownPerson = await person(companyId); + const foreignPerson = await person(otherId); + const ownSite = await site(companyId); + const foreignSite = await site(otherId); + const input = { + name: "MON-01", + templateId: monitor.id, + siteId: null, + personId: null, + status: "available", + notes: "", + characteristics: {}, + }; + assert.equal((await read()).assets.length, 0); + const created = await save(path, input); + assert.equal(created.history.length, 1); + assert.equal(created.id.charAt(14), "7"); + assert.equal(created.history[0]?.id.charAt(14), "7"); + assert.ok(created.history[0]?.actorName); + assert.ok(created.history[0]?.actorId); + assert.equal((await read(`/assets/${otherId}`)).assets.length, 0); + await error(`/assets/${otherId}/${created.id}/update`, input, "ASSET_NOT_FOUND", 404); + await error(path, { ...input, siteId: foreignSite.id }, "SITE_NOT_FOUND", 404); + await error(path, { ...input, personId: foreignPerson.id }, "PERSON_UNAVAILABLE"); + await error(path, { ...input, characteristics: { [inch.id]: "24" } }, "INVALID_CHARACTERISTICS"); + await error( + path, + { ...input, characteristics: { [ownPerson.id]: 24 } }, + "INVALID_CHARACTERISTICS" + ); + await error(path, { ...input, actorId: ownPerson.id }, "INVALID_REQUEST", 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 assigned = await save(`${path}/${created.id}/update`, { + ...input, + siteId: ownSite.id, + personId: ownPerson.id, + status: "in_use", + characteristics: { [inch.id]: 24 }, + }); + assert.equal(assigned.id, created.id); + assert.equal(assigned.history.length, 2); + assert.equal(assigned.history[0]?.values.personId, null); + const unchanged = await save(`${path}/${created.id}/update`, assigned.values); + assert.deepEqual(unchanged.history, assigned.history); + await error( + `${path}/${created.id}/update`, + { ...assigned.values, templateId: laptop.id }, + "TEMPLATE_FIXED" + ); + await error( + `${path}/${created.id}/update`, + { ...assigned.values, status: "retired" }, + "RETIRED_ASSIGNED" + ); + const deactivated = await request(`/companies/${companyId}/people/${ownPerson.id}/update`, { + name: ownPerson.name, + position: null, + email: null, + phone: null, + active: false, + isContact: false, + }); + assert.equal(deactivated.status, 200); + const kept = await save(`${path}/${created.id}/update`, { + ...assigned.values, + notes: "Persona inactiva: revisar entrega.", + }); + assert.equal(kept.values.personId, ownPerson.id); + await error(path, { ...input, personId: ownPerson.id }, "PERSON_UNAVAILABLE"); + const retired = await save(`${path}/${created.id}/update`, { + ...kept.values, + personId: null, + status: "retired", + }); + assert.equal(retired.history.length, 4); + assert.equal(retired.history[1]?.values.personId, ownPerson.id); + + const templateResponse = await request(`/asset-catalog/${companyId}`, { + values: { + kind: "template", + name: "Monitor obligatorio inventario", + description: "", + fields: [{ fieldId: inch.id, required: true }], + }, + }); + assert.equal(templateResponse.status, 201); + const templateId = SaveDefinitionResponseSchema.parse(await templateResponse.json()).savedId; + await error(path, { ...input, templateId }, "REQUIRED_CHARACTERISTICS"); + await error(`/assets/${otherId}`, { ...input, templateId }, "TEMPLATE_UNAVAILABLE"); + const custom = await save(path, { + ...input, + templateId, + name: "MON-02", + characteristics: { [inch.id]: 0 }, + }); + const revisionId = custom.history[0]?.id; + assert.equal( + ( + await request(`/asset-catalog/${companyId}/${templateId}/update`, { + values: { + kind: "template", + name: "Plantilla renombrada", + description: "", + fields: [{ fieldId: inch.id, required: false }], + }, + }) + ).status, + 200 + ); + const current = (await read()).assets.find((asset) => asset.id === custom.id); + assert.ok(current); + assert.equal(current.specification.templateName, "Monitor obligatorio inventario"); + assert.equal(current.specification.fields[0]?.required, true); + await error( + `${path}/${custom.id}/update`, + { ...custom.values, characteristics: {} }, + "REQUIRED_CHARACTERISTICS" + ); + assert.equal( + (await request(`/asset-catalog/${companyId}/${templateId}/status`, { active: false })).status, + 200 + ); + await error(path, { ...input, templateId }, "TEMPLATE_UNAVAILABLE"); + const updated = await save(`${path}/${custom.id}/update`, { + ...custom.values, + notes: "Plantilla conservada", + }); + assert.equal(updated.history[0]?.id, revisionId); + const stored = (await read()).assets.find((asset) => asset.id === updated.id); + assert.deepEqual(stored, updated); + // Corrupción cruzada simulada solo en la base de pruebas y restaurada incluso ante fallo. + const corrupt = { ...updated.values, personId: foreignPerson.id }; + const lastRevision = updated.history.at(-1); + assert.ok(lastRevision); + await database.query("UPDATE assets SET personId=:person, `values`=:values WHERE id=:id", { + replacements: { person: foreignPerson.id, values: JSON.stringify(corrupt), id: updated.id }, + }); + await database.query( + "UPDATE asset_revisions SET personId=:person, `values`=:values WHERE id=:id", + { + replacements: { + person: foreignPerson.id, + values: JSON.stringify(corrupt), + id: lastRevision.id, + }, + } + ); + try { + assert.equal((await request(path)).status, 500); + } finally { + await database.query( + "UPDATE asset_revisions SET personId=:person, `values`=:values WHERE id=:id", + { + replacements: { + person: updated.values.personId, + values: JSON.stringify(updated.values), + id: lastRevision.id, + }, + } + ); + await database.query("UPDATE assets SET personId=:person, `values`=:values WHERE id=:id", { + replacements: { + person: updated.values.personId, + values: JSON.stringify(updated.values), + id: updated.id, + }, + }); + } + assert.equal((await request("/assets/00000000-0000-4000-8000-000000000001")).status, 404); +} diff --git a/apps/web/package.json b/apps/web/package.json index f913751..3789e19 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -20,7 +20,8 @@ "tailwindcss": "4.2.2", "@repo/shadcn-ui": "workspace:*", "@metis/identity": "workspace:*", - "@metis/companies": "workspace:*" + "@metis/companies": "workspace:*", + "@metis/assets": "workspace:*" }, "devDependencies": { "@repo/typescript-config": "workspace:*", diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index b7f25a8..d1b917a 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -1,3 +1,4 @@ +import { AssetCatalogPage } from "@metis/assets/client"; import { AccessWorkspacePage, CompaniesPage } from "@metis/companies/client"; import { HomePage } from "@metis/core/client"; import { IdentityWorkspace } from "@metis/identity/client"; @@ -18,6 +19,7 @@ const LANGUAGE_OPTIONS = [ export function App() { const { t: companyTranslation } = useTranslation("companies"); + const { t: assetTranslation } = useTranslation("assets"); const { t: accessTranslation } = useTranslation("access"); const { t, i18n } = useTranslation("core"); const [languageError, setLanguageError] = useState(false); @@ -76,6 +78,11 @@ export function App() { ), }, + { + id: "asset-catalog", + label: assetTranslation("title"), + render: (session) => , + }, { id: "companies", label: companyTranslation("title"), diff --git a/apps/web/src/i18n.ts b/apps/web/src/i18n.ts index a2ae81f..773a295 100644 --- a/apps/web/src/i18n.ts +++ b/apps/web/src/i18n.ts @@ -1,3 +1,4 @@ +import { assetsEnglish, assetsSpanish } from "@metis/assets/client"; import { accessEnglish, accessSpanish, @@ -21,12 +22,14 @@ export function createAppI18n(language = "es") { identity: identitySpanish, companies: companiesSpanish, access: accessSpanish, + assets: assetsSpanish, }, en: { core: coreEnglish, identity: identityEnglish, companies: companiesEnglish, access: accessEnglish, + assets: assetsEnglish, }, }, interpolation: { escapeValue: false }, diff --git a/docs/arquitectura/modelo-ddd-e-identificadores.md b/docs/arquitectura/modelo-ddd-e-identificadores.md index c605443..ab1b114 100644 --- a/docs/arquitectura/modelo-ddd-e-identificadores.md +++ b/docs/arquitectura/modelo-ddd-e-identificadores.md @@ -45,6 +45,10 @@ Company es la raíz del agregado de compañías clientes. Person, Department, De AccessWorkspace es una raíz adicional para el catálogo global o la configuración informática de una compañía. Cada espacio conserva su propio UUID. AccessItem y AccessRevision son entidades con UUID propio. AccessValues encapsula la definición tipada e inmutable, con identificadores y fechas reconstruidos como UniqueID y CalendarDate. Las referencias relacionales preservan las dependencias y el historial conserva el actor autenticado. Consultar [Cuentas, grupos y recursos](../cuentas-grupos-y-recursos.md). -Los módulos de documentos, tickets y assets 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. +AssetCatalog es la raíz del catálogo de activos; AssetDefinition, TemplateField y CatalogAvailability son entidades con UUID propio. AssetDefinitionValues reconstruye nombres y referencias como Value Objects. Consultar [Campos y plantillas de activos](../campos-y-plantillas-de-activos.md). + +Asset es la raíz de cada activo real y AssetRevision una entidad con UUID propio, instantánea de valores, actor y fecha. AssetValues y AssetSpecification son Value Objects inmutables. La especificación capturada conserva los campos y reglas de la ficha aunque cambie el catálogo. Consultar [Inventario de activos](../inventario-de-activos.md). + +Los módulos de documentos y tickets todavía no existen. Se declararán como agregados o entidades al implementar sus operaciones y reglas de consistencia. La decisión de pertenencia a un agregado se toma por sus invariantes y transacciones; no convierte automáticamente cada tabla en una raíz ni fuerza todo el cliente y sus tickets dentro de un solo agregado. Cada entidad que se incorpore deberá tener UniqueID desde su primera implementación, mapper de persistencia y pruebas de identidad, invariantes, rehidratación y actualización. diff --git a/docs/campos-y-plantillas-de-activos.md b/docs/campos-y-plantillas-de-activos.md new file mode 100644 index 0000000..68b1eb9 --- /dev/null +++ b/docs/campos-y-plantillas-de-activos.md @@ -0,0 +1,46 @@ + +# Campos y plantillas de activos + +## Uso + +El Catálogo de activos de la navegación principal contiene las definiciones globales. La ficha de cada compañía incorpora Plantillas de activos: combina las globales con sus adiciones propias. + +Las altas y ediciones se abren mediante el botón de cabecera o la acción del listado. Los formularios son modales, con ayuda inferior y Guardar / Cancelar. Ver consulta una definición sin modificarla. Crear copia inicia una nueva plantilla personalizable. + +Un campo representa una característica reutilizable: texto, número, fecha, sí/no o lista de valores. Tiene nombre, descripción y unidad opcionales. Una lista necesita valores únicos; el tipo queda fijo tras el alta y sus valores existentes no pueden eliminarse. Se pueden añadir valores. Las credenciales no pertenecen a este catálogo. + +Una plantilla reúne campos para un tipo de activo. No representa un activo concreto ni un modelo comercial. Cada campo comienza como opcional; el técnico puede marcarlo obligatorio en plantillas propias. Se pueden añadir y retirar campos. Retirar una asociación no elimina la definición del campo. El orden de incorporación se conserva. + +El selector de campos reutiliza SelectWithAction: el botón «+» abre el alta de campo, conserva los datos de la plantilla y añade el nuevo campo como opcional. Cancelar el modal adicional conserva la plantilla. Un campo guardado permanece en el catálogo aunque se cancele después la plantilla. + +## Datos de serie + +Se incluyen 30 campos y 12 plantillas: portátil, PC de sobremesa, monitor, impresora, router, firewall, switch, punto de acceso Wi-Fi, NAS, servidor, smartphone y SAI. + +Los campos cubren marca, modelo, número de serie, compra y garantía; procesador, RAM, almacenamiento y sistema operativo; pantalla, resolución y conexiones; IP, MAC, puertos, velocidad, PoE y Wi-Fi; discos, capacidad y RAID; impresión; telefonía; potencia y autonomía. + +Todas las características de las plantillas de serie son opcionales. Las definiciones de serie no se pueden renombrar ni alterar, tampoco mediante la API. Se pueden activar o desactivar. Para cambiar los campos de una plantilla de serie, crear una copia. No hay borrado de definiciones. + +El arranque alimenta únicamente definiciones ausentes mediante claves de serie estables. Reiniciar no duplica registros, regenera UUID ni reactiva definiciones desactivadas. No crea compañías, personas ni activos ficticios. + +## Ámbitos y disponibilidad + +Una definición global está disponible para todas las compañías. Una adición local solo está visible en su compañía. No se pueden editar definiciones globales desde una compañía; se puede crear una copia de una plantilla. + +La disponibilidad global es el valor predeterminado. Cada compañía puede establecer una activación o desactivación propia de una definición global, sin afectar a otras. Usar configuración global elimina el efecto del ajuste local, conservando su UUID. + +No se aceptan nombres duplicados del mismo tipo en el catálogo visible, ignorando mayúsculas y espacios exteriores. Una nueva definición global tampoco puede colisionar con una adición local. + +Las plantillas globales solo usan campos globales. Las locales pueden usar campos globales o de su propia compañía. Solo se añaden campos activos. Desactivar un campo conserva las asociaciones existentes y muestra un aviso; no desactiva automáticamente plantillas ni borra información. + +## Arquitectura + +El módulo assets sigue domain, application e infrastructure. AssetCatalog es la raíz de consistencia; AssetDefinition, TemplateField y CatalogAvailability son entidades. Todas tienen UniqueID; las nuevas identidades son UUID v7. AssetDefinitionValues reconstruye Name y referencias UniqueID y compara cambios semánticamente. Los cambios idempotentes conservan instancias e identificadores. + +Las tablas asset_catalogs, asset_definitions, asset_template_fields y asset_availability tienen claves primarias y foráneas explícitas. Las migraciones son idempotentes. Un bloqueo de catálogo serializa altas, cambios de nombres, campos asociados y disponibilidad para evitar colisiones concurrentes. El mapper reconstruye y comprueba definiciones y asociaciones persistidas. + +Las rutas GET /api/asset-catalog/:scope, POST /api/asset-catalog/:scope, POST /api/asset-catalog/:scope/:id/update y POST /api/asset-catalog/:scope/:id/status requieren sesión. Las escrituras usan la protección de origen y CSRF existente. scope es global o el UUID de una compañía existente. La respuesta solo expone el catálogo visible de ese ámbito. + +## Siguiente entrega + +Registrar activos reales utilizando estas plantillas, con compañía, sede, asignación a personas y movimientos. Las relaciones y conjuntos de puesto de trabajo se incorporarán después. Este bloque no registra todavía inventario, configuración por dispositivo ni relaciones entre activos. diff --git a/docs/componentes-de-interfaz.md b/docs/componentes-de-interfaz.md index fbc493c..a9ecb91 100644 --- a/docs/componentes-de-interfaz.md +++ b/docs/componentes-de-interfaz.md @@ -85,3 +85,7 @@ La revisión de los formularios extiende este patrón a personas, departamentos, Los entornos necesitan un sistema activo seleccionado. Los permisos nuevos heredan el tipo del recurso elegido. Al crear un grupo desde una pertenencia, sistema y entorno quedan fijados a los de la cuenta. Las plantillas siguen siendo globales; el modal lo explica y carga únicamente sus opciones globales. Los elementos creados desde un selector están activos para poder seleccionarlos. Uso de cuenta, estado de configuración e idioma son listas cerradas. Las referencias históricas de cobertura (necesidad, autorización, titularidad y pertenencia) conservan sus operaciones propias. Los campos inmutables en edición no ofrecen altas. Los permisos múltiples de una necesidad continúan como casillas. + +El catálogo de activos utiliza SelectWithAction para añadir campos a una plantilla y crearlos mediante un modal adicional. Los tipos de dato constituyen una lista cerrada. Las definiciones de serie se consultan en modales de lectura; su personalización se realiza mediante copias. + +El inventario añade altas rápidas de plantilla, sede y persona en los activos. La plantilla creada desde un activo solo ofrece campos existentes para mantener un único nivel adicional de modal. Las características utilizan controles según su tipo; los valores sí/no conservan un estado vacío distinto de No. diff --git a/docs/inventario-de-activos.md b/docs/inventario-de-activos.md new file mode 100644 index 0000000..e44af6e --- /dev/null +++ b/docs/inventario-de-activos.md @@ -0,0 +1,45 @@ +# Inventario de activos + +## Uso + +La ficha de compañía incorpora la sección Activos. El listado permite buscar por nombre, plantilla, estado, sede o persona. Nuevo activo abre un modal; Editar y Ficha e historial son acciones explícitas. No se muestran formularios junto al listado. + +Un activo representa un elemento concreto, identificado mediante UUID v7. Su nombre sirve para reconocerlo (por ejemplo PORT-ANA-01), pero no sustituye al UUID y puede repetirse. La compañía y la plantilla son obligatorias. Sede, persona, notas y características son opcionales salvo los campos marcados obligatorios en la plantilla. + +Estados: Disponible, En uso, En mantenimiento y Retirado. Estado y asignación se registran de forma explícita: un equipo compartido puede estar en uso sin una persona única. Para retirarlo debe quedar sin persona asignada; puede conservar una sede y todo su historial. No hay borrado de activos ni transferencia entre compañías. + +La sede y la persona pertenecen a la compañía. Solo se permiten nuevas asignaciones a personas activas. Una persona que se desactiva conserva su asignación anterior, para facilitar la revisión y retirada; puede desasignarse. Las personas no reciben acceso a Metis Desk. + +## Características y plantillas + +El alta utiliza una plantilla activa global o propia de la compañía. Se captura una copia de su nombre, campos, tipos, unidades, valores de lista y obligatoriedad. La plantilla del activo queda fija. Cambiar o desactivar la definición del catálogo no cambia las fichas ni las revisiones existentes. Un activo existente sigue siendo editable aunque su plantilla se desactive. + +Esta decisión evita que una edición del catálogo elimine características o convierta datos ya guardados en inválidos. Las nuevas características y opciones incorporadas posteriormente a una plantilla se aplican a los nuevos activos. La adaptación de fichas existentes a futuras versiones queda fuera de esta entrega. + +Se validan texto, número, fecha real, sí/no y lista. No se aceptan campos ajenos a la especificación, tipos incorrectos ni opciones de lista desconocidas. Los valores cero y falso se conservan y satisfacen la obligatoriedad; un campo sin completar se omite. No se almacenan contraseñas mediante este catálogo. + +## Alta rápida y ayuda + +Plantilla, sede y persona reutilizan SelectWithAction. El botón «+» abre un modal adicional; guardar selecciona el elemento creado sin perder los datos del activo y cancelar conserva el formulario principal. La nueva plantilla es exclusiva de esa compañía. + +Solo se permite un nivel de alta adicional: al crear una plantilla desde el activo se seleccionan campos existentes, sin abrir un tercer modal de alta de campo. Los elementos guardados permanecen aunque después se cancele el activo. El modal explica este comportamiento. Los estados y las opciones de una característica son valores cerrados para esa ficha. + +La ayuda aparece inmediatamente antes de Guardar y Cancelar. Los formularios amplios tienen desplazamiento y conservan navegación por teclado. La fecha se confirma al introducirla y se conserva al actualizar otros campos o cancelar un alta relacionada. + +## Historial y arquitectura + +Asset es la raíz de consistencia de cada activo. AssetRevision conserva una instantánea completa de cada cambio con UUID propio, actor autenticado y fecha UTC. AssetValues y AssetSpecification son Value Objects inmutables con igualdad semántica. Las referencias se reconstruyen como UniqueID; Name representa los nombres. + +El alta crea una primera revisión. Cambiar datos, estado, sede, persona o características añade una revisión, conservando la identidad del activo y las revisiones anteriores. Guardar los mismos valores normalizados no crea otra revisión. La ficha muestra todas las revisiones y sus valores, no solo un contador. + +Las migraciones explícitas crean assets y asset_revisions, con claves foráneas para compañía, plantilla, sede, persona y actor; las revisiones también conservan las referencias de sede y persona. Infrastructure reconstruye y comprueba identidades, tipos, historial y pertenencia. No regenera identificadores ni sustituye datos corruptos. + +Las escrituras son transaccionales, bloquean catálogo y compañía en un orden fijo y guardan únicamente revisiones nuevas. El repositorio valida la pertenencia de referencias y el ámbito del activo. Las operaciones se mantienen en domain, application e infrastructure; Sequelize queda en Infrastructure. + +GET /api/assets/:companyId consulta el inventario. POST /api/assets/:companyId y POST /api/assets/:companyId/:id/update crean y editan. Todas requieren sesión; las escrituras usan origen y CSRF. El actor procede de la sesión, nunca del cuerpo enviado por el navegador. + +## Validación y siguientes entregas + +Las pruebas verifican características opcionales y obligatorias, tipos, fechas, cero y falso, UUID, igualdad semántica, aislamiento por compañía, historial, desactivaciones, rehidratación y protección de las escrituras. La comprobación en navegador incluye alta rápida, cancelación, conservación de fechas y consulta del historial. + +Las relaciones entre activos y los conjuntos de puesto de trabajo se incorporarán en el siguiente bloque. Configuración por dispositivo, teletrabajo, inventario de dispositivos personales y procesos de salida mantienen su orden en el plan. diff --git a/docs/plan-de-desarrollo.md b/docs/plan-de-desarrollo.md index 8e70838..c81e1c2 100644 --- a/docs/plan-de-desarrollo.md +++ b/docs/plan-de-desarrollo.md @@ -35,20 +35,24 @@ Personas sin acceso a Metis Desk, contactos como clasificación, puesto general Catálogos globales con adiciones por compañía, entornos, cuentas con uso fijo y caducidad opcional, relaciones con personas, plantillas globales de grupos, pertenencias sin anidación, recursos y permisos configurables, necesidades y cobertura calculada. Las autorizaciones y configuraciones se registran por separado, con historial, actor y avisos de revisión. Consultar [Cuentas, grupos y recursos](cuentas-grupos-y-recursos.md). -## Siguientes entregas: orden propuesto +## Campos y plantillas de activos: implementado -1. Perfil de configuración por dispositivo: 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. -4. Modelos de activos, inventario, relaciones y conjuntos de puesto de trabajo. +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). + +## Siguientes entregas: orden actualizado + +1. Relaciones entre activos y conjuntos de puesto de trabajo. +2. Perfil de configuración por dispositivo: correos con POP/IMAP, carpetas compartidas, impresoras y recursos. +3. Teletrabajo y accesos remotos: autorización, equipo de origen, conectividad VPN y destino. Asistencia técnica remota independiente. +4. Procesos de alta, cambio y salida con seguimiento de configuraciones y retiradas. Plantillas reutilizables entre compañías; ejecuciones y datos propios. Este orden se concreta antes de cada entrega. Las licencias y su relación con cuentas se explorarán posteriormente. -## Inventario: pendiente +## Inventario: implementado -Infraestructura por sede: routers, firewalls, switches, puntos de acceso, NAS y servidores. -Puestos de trabajo y asignaciones: equipos, monitores, impresoras y usuarios. -Definir identificación, estado e historial de movimientos. +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). + +Los conjuntos de puesto de trabajo y las relaciones entre activos siguen pendientes. ## Tickets e intervenciones: pendiente @@ -62,4 +66,4 @@ Las credenciales se resolverán con una solución específica; no almacenarlas c ## Decisiones pendientes -Permisos por técnico si llegan a necesitarse, despliegue, backups, recuperación administrativa y modelo de organización. El acceso y los usuarios ya están implementados; la base de compañías clientes, personas, departamentos, asignaciones y sedes está implementada. Inventario y tickets siguen pendientes. +Permisos por técnico si llegan a necesitarse, despliegue, backups, recuperación administrativa y modelo de organización. El acceso y los usuarios ya están implementados; la base de compañías clientes, personas, departamentos, asignaciones y sedes está implementada. El inventario está implementado; las relaciones entre activos y los tickets siguen pendientes. diff --git a/modules/assets/package.json b/modules/assets/package.json new file mode 100644 index 0000000..39e08c1 --- /dev/null +++ b/modules/assets/package.json @@ -0,0 +1,40 @@ +{ + "name": "@metis/assets", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + "./common": "./src/common/index.ts", + "./module": "./src/api/module.ts", + "./client": "./src/web/index.ts" + }, + "scripts": { + "typecheck": "tsc -p tsconfig.json", + "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/web/catalog.test.tsx src/web/inventory.test.tsx" + }, + "dependencies": { + "@repo/rdx-ddd": "workspace:*", + "@repo/rdx-utils": "workspace:*", + "@repo/shadcn-ui": "workspace:*", + "sequelize": "6.37.8", + "zod": "4.3.6", + "react-hook-form": "7.72.1", + "@hookform/resolvers": "5.2.2", + "@tanstack/react-table": "8.21.3" + }, + "devDependencies": { + "@repo/typescript-config": "workspace:*", + "@types/node": "25.6.0", + "@types/express": "4.17.21", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3" + }, + "peerDependencies": { + "express": "4.22.1", + "react": "19.2.5", + "react-dom": "19.2.5", + "i18next": "26.0.4", + "react-i18next": "17.0.2", + "@tanstack/react-query": "5.98.0" + } +} diff --git a/modules/assets/src/api/application/asset-catalog-response.builder.ts b/modules/assets/src/api/application/asset-catalog-response.builder.ts new file mode 100644 index 0000000..fa8001e --- /dev/null +++ b/modules/assets/src/api/application/asset-catalog-response.builder.ts @@ -0,0 +1,31 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import type { Maybe } from "@repo/rdx-utils"; + +import type { CatalogResponseDTO } from "../../common"; +import type { AssetCatalog } from "../domain/asset-catalog.aggregate"; +export function buildCatalogResponse( + catalog: AssetCatalog, + scope: Maybe +): CatalogResponseDTO { + return { + items: catalog.visible(scope).map((item) => ({ + id: item.id.toPrimitive(), + companyId: item.companyId.getOrUndefined()?.toPrimitive() ?? null, + builtin: item.stockKey.getOrUndefined() !== undefined, + active: item.active, + effectiveActive: catalog.active(item, scope), + overridden: catalog.availability.some( + (entry) => + entry.companyId.equals(scope.getOrUndefined()) && + entry.definitionId.equals(item.id) && + entry.active.getOrUndefined() !== undefined + ), + values: item.values.data, + bindings: item.bindings.map((binding) => ({ + id: binding.id.toPrimitive(), + fieldId: binding.fieldId.toPrimitive(), + required: binding.required, + })), + })), + }; +} diff --git a/modules/assets/src/api/application/asset-catalog.repository.ts b/modules/assets/src/api/application/asset-catalog.repository.ts new file mode 100644 index 0000000..14e6081 --- /dev/null +++ b/modules/assets/src/api/application/asset-catalog.repository.ts @@ -0,0 +1,12 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import type { Maybe, Result } from "@repo/rdx-utils"; + +import type { AssetCatalog } from "../domain/asset-catalog.aggregate"; +import type { AssetCatalogError } from "../domain/asset-definition"; +export interface IAssetCatalogRepository { + get(scope: Maybe): Promise>; + update( + scope: Maybe, + action: (catalog: AssetCatalog) => Result + ): Promise>; +} diff --git a/modules/assets/src/api/application/asset-catalog.use-cases.ts b/modules/assets/src/api/application/asset-catalog.use-cases.ts new file mode 100644 index 0000000..17c78c1 --- /dev/null +++ b/modules/assets/src/api/application/asset-catalog.use-cases.ts @@ -0,0 +1,55 @@ +import { UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import type { AssetDefinitionSpec } from "../../common"; +import { AssetCatalogError } from "../domain/asset-definition"; +import { AssetDefinitionValues } from "../domain/value-objects/asset-definition-values"; + +import type { IAssetCatalogRepository } from "./asset-catalog.repository"; + +export function catalogScope(value: string) { + if (value === "global") { + return Result.ok(Maybe.none()); + } + const id = UniqueID.create(value); + return id.isSuccess + ? Result.ok(Maybe.some(id.data)) + : Result.fail(new AssetCatalogError("INVALID_REQUEST")); +} +export class AssetCatalogUseCases { + constructor(private readonly repository: IAssetCatalogRepository) {} + async get(scope: string) { + const parsed = catalogScope(scope); + return parsed.isSuccess ? this.repository.get(parsed.data) : parsed; + } + async save(scope: string, identity: string | undefined, input: AssetDefinitionSpec) { + const parent = catalogScope(scope); + const values = AssetDefinitionValues.create(input); + const id = identity ? UniqueID.create(identity) : Result.ok(undefined); + if (!parent.isSuccess) { + return parent; + } + if (!values.isSuccess) { + return values; + } + if (!id.isSuccess) { + return Result.fail(new AssetCatalogError("INVALID_REQUEST")); + } + return this.repository.update(parent.data, (catalog) => + catalog.save(parent.data, id.data ? Maybe.some(id.data) : Maybe.none(), values.data) + ); + } + async setActive(scope: string, identity: string, active: boolean | null) { + const parent = catalogScope(scope); + const id = UniqueID.create(identity); + if (!parent.isSuccess) { + return parent; + } + if (!id.isSuccess) { + return Result.fail(new AssetCatalogError("INVALID_REQUEST")); + } + return this.repository.update(parent.data, (catalog) => + catalog.setActive(parent.data, id.data, active) + ); + } +} diff --git a/modules/assets/src/api/application/asset-input.mapper.ts b/modules/assets/src/api/application/asset-input.mapper.ts new file mode 100644 index 0000000..ead36be --- /dev/null +++ b/modules/assets/src/api/application/asset-input.mapper.ts @@ -0,0 +1,60 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import type { Asset } from "../domain/asset.aggregate"; +import { AssetCatalogError } from "../domain/asset-definition"; +import type { AssetInput } from "../domain/asset-values"; +import { AssetSpecification, AssetValues } from "../domain/value-objects/asset-values"; + +import type { InventoryContext } from "./asset.repository"; + +export function mapAssetInput( + input: AssetInput, + companyId: UniqueID, + context: InventoryContext, + previous?: Asset +) { + if (previous && previous.values.toPrimitive().templateId !== input.templateId.toLowerCase()) { + return Result.fail(new AssetCatalogError("TEMPLATE_FIXED")); + } + let specification = previous?.specification; + if (!specification) { + const template = context.catalog + .visible(Maybe.some(companyId)) + .find((item) => item.id.toPrimitive() === input.templateId.toLowerCase()); + if ( + !template || + template.values.data.kind !== "template" || + !context.catalog.active(template, Maybe.some(companyId)) + ) { + return Result.fail(new AssetCatalogError("TEMPLATE_UNAVAILABLE")); + } + const fields = template.values.data.fields.map((binding) => { + const field = context.catalog.definitions.find( + (item) => item.id.toPrimitive() === binding.fieldId + ); + if (!field || field.values.data.kind !== "field") { + throw new Error("Plantilla persistida inconsistente."); + } + return { + fieldId: field.id.toPrimitive(), + name: field.values.data.name, + type: field.values.data.type, + unit: field.values.data.unit, + choices: [...field.values.data.choices], + required: binding.required, + }; + }); + const created = AssetSpecification.create({ + templateId: template.id.toPrimitive(), + templateName: template.values.data.name, + fields, + }); + if (!created.isSuccess) { + return created; + } + specification = created.data; + } + const values = AssetValues.create(input, specification); + return values.isSuccess ? Result.ok({ specification, values: values.data }) : values; +} diff --git a/modules/assets/src/api/application/asset-response.builder.ts b/modules/assets/src/api/application/asset-response.builder.ts new file mode 100644 index 0000000..e3e494c --- /dev/null +++ b/modules/assets/src/api/application/asset-response.builder.ts @@ -0,0 +1,17 @@ +import type { AssetDTO } from "../../common/inventory"; +import type { Asset } from "../domain/asset.aggregate"; +export function buildAssetResponse(asset: Asset, actors: ReadonlyMap): AssetDTO { + return { + id: asset.id.toPrimitive(), + companyId: asset.companyId.toPrimitive(), + specification: asset.specification.toPrimitive(), + values: asset.values.toPrimitive(), + history: asset.history.map((revision) => ({ + id: revision.id.toPrimitive(), + values: revision.values.toPrimitive(), + actorId: revision.actorId.toPrimitive(), + actorName: actors.get(revision.actorId.toPrimitive()) ?? revision.actorId.toPrimitive(), + recordedAt: revision.recordedAt, + })), + }; +} diff --git a/modules/assets/src/api/application/asset.repository.ts b/modules/assets/src/api/application/asset.repository.ts new file mode 100644 index 0000000..fd5a990 --- /dev/null +++ b/modules/assets/src/api/application/asset.repository.ts @@ -0,0 +1,40 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import type { Asset } from "../domain/asset.aggregate"; +import type { AssetCatalog } from "../domain/asset-catalog.aggregate"; +import { AssetCatalogError } from "../domain/asset-definition"; +import type { AssetValues } from "../domain/value-objects/asset-values"; + +export type InventoryContext = Readonly<{ + catalog: AssetCatalog; + assets: readonly Asset[]; + sites: readonly UniqueID[]; + people: readonly { id: UniqueID; active: boolean }[]; + actorNames: ReadonlyMap; +}>; +export interface IAssetRepository { + get(companyId: UniqueID): Promise>; + save( + companyId: UniqueID, + action: (context: InventoryContext) => Result + ): Promise }, AssetCatalogError>>; +} +export function validateAssetReferences( + context: InventoryContext, + values: AssetValues, + previous?: Asset +) { + const next = values.toPrimitive(); + const current = previous?.values.toPrimitive(); + if (next.siteId && !context.sites.some((site) => site.toPrimitive() === next.siteId)) { + return Result.fail(new AssetCatalogError("SITE_NOT_FOUND")); + } + if (next.personId) { + const person = context.people.find((person) => person.id.toPrimitive() === next.personId); + if (!person || (!person.active && current?.personId !== next.personId)) { + return Result.fail(new AssetCatalogError("PERSON_UNAVAILABLE")); + } + } + return Result.ok(true); +} diff --git a/modules/assets/src/api/application/asset.use-cases.ts b/modules/assets/src/api/application/asset.use-cases.ts new file mode 100644 index 0000000..d68b5cd --- /dev/null +++ b/modules/assets/src/api/application/asset.use-cases.ts @@ -0,0 +1,54 @@ +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { Asset } from "../domain/asset.aggregate"; +import { AssetCatalogError } from "../domain/asset-definition"; +import type { AssetInput } from "../domain/asset-values"; + +import type { IAssetRepository } from "./asset.repository"; +import { validateAssetReferences } from "./asset.repository"; +import { mapAssetInput } from "./asset-input.mapper"; +export class AssetUseCases { + constructor(private readonly repository: IAssetRepository) {} + get(companyId: string) { + const company = UniqueID.create(companyId); + if (!company.isSuccess) { + return Promise.resolve(Result.fail(new AssetCatalogError("INVALID_REQUEST"))); + } + return this.repository.get(company.data); + } + save(companyId: string, id: string | undefined, input: AssetInput, 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 AssetCatalogError("INVALID_REQUEST"))); + } + return this.repository.save(company.data, (context) => { + const previous = identity?.isSuccess + ? context.assets.find((asset) => asset.id.equals(identity.data)) + : undefined; + if (id && !previous) { + return Result.fail(new AssetCatalogError("ASSET_NOT_FOUND")); + } + const mapped = mapAssetInput(input, company.data, context, previous); + if (!mapped.isSuccess) { + return Result.fail(mapped.error); + } + const references = validateAssetReferences(context, mapped.data.values, previous); + if (!references.isSuccess) { + return Result.fail(references.error); + } + const now = new Date().toISOString(); + return previous + ? previous.update(mapped.data.values, actor.data, now) + : Asset.create( + company.data, + mapped.data.specification, + mapped.data.values, + actor.data, + now + ); + }); + } +} diff --git a/modules/assets/src/api/domain/asset-catalog-seed.ts b/modules/assets/src/api/domain/asset-catalog-seed.ts new file mode 100644 index 0000000..51e8435 --- /dev/null +++ b/modules/assets/src/api/domain/asset-catalog-seed.ts @@ -0,0 +1,104 @@ +import type { AssetCatalog } from "./asset-catalog.aggregate"; +import type { AssetDefinitionSpec, AssetFieldType } from "./asset-definition"; +import { AssetDefinitionValues } from "./value-objects/asset-definition-values"; + +const FIELDS: readonly [string, string, AssetFieldType, string][] = [ + ["brand", "Marca", "text", ""], + ["model", "Modelo", "text", ""], + ["serial", "Número de serie", "text", ""], + ["processor", "Procesador", "text", ""], + ["memory", "Memoria RAM", "number", "GB"], + ["storage", "Almacenamiento", "text", ""], + ["os", "Sistema operativo", "text", ""], + ["hostname", "Nombre de equipo", "text", ""], + ["screen_size", "Pulgadas", "number", "pulgadas"], + ["resolution", "Resolución", "text", ""], + ["connections", "Conexiones", "text", ""], + ["ip", "Dirección IP", "text", ""], + ["mac", "Dirección MAC", "text", ""], + ["ports", "Número de puertos", "number", ""], + ["speed", "Velocidad de red", "text", ""], + ["poe", "PoE", "boolean", ""], + ["wifi", "Estándar Wi-Fi", "text", ""], + ["capacity", "Capacidad útil", "text", ""], + ["disks", "Discos", "text", ""], + ["raid", "RAID", "text", ""], + ["printing", "Tecnología de impresión", "text", ""], + ["color", "Impresión en color", "boolean", ""], + ["duplex", "Doble cara", "boolean", ""], + ["imei", "IMEI", "text", ""], + ["phone", "Número de teléfono", "text", ""], + ["carrier", "Operador móvil", "text", ""], + ["power", "Potencia", "number", "VA"], + ["battery", "Autonomía", "number", "minutos"], + ["purchased_on", "Fecha de compra", "date", ""], + ["warranty_until", "Fin de garantía", "date", ""], +]; +const COMMON = ["brand", "model", "serial", "purchased_on", "warranty_until"]; +const TEMPLATES: readonly [string, string, readonly string[]][] = [ + [ + "laptop", + "Portátil", + [ + "processor", + "memory", + "storage", + "os", + "hostname", + "screen_size", + "resolution", + "connections", + "ip", + "mac", + ], + ], + [ + "desktop", + "PC de sobremesa", + ["processor", "memory", "storage", "os", "hostname", "connections", "ip", "mac"], + ], + ["monitor", "Monitor", ["screen_size", "resolution", "connections"]], + ["printer", "Impresora", ["printing", "color", "duplex", "connections", "ip", "mac"]], + ["router", "Router", ["ports", "speed", "wifi", "ip", "mac"]], + ["firewall", "Firewall", ["ports", "speed", "ip", "mac"]], + ["switch", "Switch", ["ports", "speed", "poe", "ip", "mac"]], + ["access_point", "Punto de acceso Wi-Fi", ["wifi", "poe", "ip", "mac"]], + ["nas", "NAS", ["processor", "memory", "disks", "capacity", "raid", "os", "ip", "mac"]], + [ + "server", + "Servidor", + ["processor", "memory", "storage", "disks", "raid", "os", "hostname", "ip", "mac"], + ], + ["smartphone", "Smartphone", ["os", "storage", "screen_size", "imei", "phone", "carrier"]], + ["ups", "SAI", ["power", "battery", "connections"]], +]; +export function seedAssetCatalog(catalog: AssetCatalog) { + let current = catalog; + const add = (key: string, input: AssetDefinitionSpec) => { + const values = AssetDefinitionValues.create(input); + if (!values.isSuccess) { + throw new Error("Definición inicial de activos inválida."); + } + const result = current.seed(key, values.data); + if (!result.isSuccess) { + throw new Error("Catálogo inicial de activos inconsistente."); + } + current = result.data; + }; + for (const [key, name, type, unit] of FIELDS) { + add(`field:${key}`, { kind: "field", name, description: "", type, unit, choices: [] }); + } + for (const [key, name, fields] of TEMPLATES) { + const references = [...COMMON, ...fields].map((field) => { + const id = current.definitions + .find((item) => item.stockKey.getOrUndefined() === `field:${field}`) + ?.id.toPrimitive(); + if (!id) { + throw new Error("Campo inicial de activos ausente."); + } + return { fieldId: id, required: false }; + }); + add(`template:${key}`, { kind: "template", name, description: "", fields: references }); + } + return current; +} diff --git a/modules/assets/src/api/domain/asset-catalog.aggregate.ts b/modules/assets/src/api/domain/asset-catalog.aggregate.ts new file mode 100644 index 0000000..4e5bfbe --- /dev/null +++ b/modules/assets/src/api/domain/asset-catalog.aggregate.ts @@ -0,0 +1,181 @@ +import { AggregateRoot, type UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { AssetCatalogError } from "./asset-definition"; +import { AssetDefinition, CatalogAvailability } from "./asset-definition.entity"; +import type { AssetDefinitionValues } from "./value-objects/asset-definition-values"; + +type CatalogProps = { + definitions: readonly AssetDefinition[]; + availability: readonly CatalogAvailability[]; +}; +export class AssetCatalog extends AggregateRoot { + private constructor(props: CatalogProps, id?: UniqueID) { + super( + { + definitions: Object.freeze([...props.definitions]), + availability: Object.freeze([...props.availability]), + }, + id + ); + } + static create() { + return new AssetCatalog({ definitions: [], availability: [] }); + } + static rehydrate(props: CatalogProps, id: UniqueID) { + return new AssetCatalog(props, id); + } + get definitions() { + return this.props.definitions; + } + get availability() { + return this.props.availability; + } + visible(companyId: Maybe) { + return this.definitions.filter( + (item) => + !item.companyId.getOrUndefined() || + item.companyId.getOrUndefined()?.equals(companyId.getOrUndefined()) + ); + } + active(item: AssetDefinition, companyId: Maybe) { + const override = this.availability.find( + (entry) => + entry.companyId.equals(companyId.getOrUndefined()) && entry.definitionId.equals(item.id) + ); + return override?.active.getOrUndefined() ?? item.active; + } + save( + companyId: Maybe, + id: Maybe, + values: AssetDefinitionValues + ): Result { + const previous = id.getOrUndefined() + ? this.definitions.find((item) => item.id.equals(id.getOrUndefined())) + : undefined; + if (id.getOrUndefined() && !(previous && sameScope(previous.companyId, companyId))) { + return Result.fail(new AssetCatalogError("DEFINITION_NOT_FOUND")); + } + const candidate = previous + ? previous.update(values) + : Result.ok(AssetDefinition.create(companyId, values, true)); + if (!candidate.isSuccess) { + return candidate; + } + return this.replace(candidate.data, companyId, previous); + } + private replace( + candidate: AssetDefinition, + scope: Maybe, + previous?: AssetDefinition + ): Result { + const conflicts = scope.getOrUndefined() ? this.visible(scope) : this.definitions; + if ( + conflicts.some( + (item) => + !item.id.equals(candidate.id) && + item.values.data.kind === candidate.values.data.kind && + item.values.data.name.toLocaleLowerCase("es") === + candidate.values.data.name.toLocaleLowerCase("es") + ) + ) { + return Result.fail(new AssetCatalogError("NAME_EXISTS")); + } + const invalidField = this.invalidField(candidate, scope, previous); + if (invalidField) { + return Result.fail(new AssetCatalogError("FIELD_UNAVAILABLE")); + } + if (candidate === previous) { + return Result.ok(this); + } + return Result.ok( + new AssetCatalog( + { + ...this.props, + definitions: previous + ? this.definitions.map((item) => (item.id.equals(candidate.id) ? candidate : item)) + : [...this.definitions, candidate], + }, + this.id + ) + ); + } + private invalidField( + candidate: AssetDefinition, + scope: Maybe, + previous?: AssetDefinition + ) { + return candidate.values.fieldIds.some((id) => { + const field = this.visible(scope).find((item) => item.id.equals(id)); + if (!field || field.values.data.kind !== "field") { + return true; + } + return !( + this.active(field, scope) || + previous?.values.fieldIds.some((existing) => existing.equals(id)) + ); + }); + } + seed(stockKey: string, values: AssetDefinitionValues): Result { + if (this.definitions.some((item) => item.stockKey.getOrUndefined() === stockKey)) { + return Result.ok(this); + } + return this.replace( + AssetDefinition.create(Maybe.none(), values, true, Maybe.some(stockKey)), + Maybe.none() + ); + } + setActive( + scope: Maybe, + id: UniqueID, + active: boolean | null + ): Result { + const item = this.visible(scope).find((entry) => entry.id.equals(id)); + if (!item) { + return Result.fail(new AssetCatalogError("DEFINITION_NOT_FOUND")); + } + const company = scope.getOrUndefined(); + if (company && !item.companyId.getOrUndefined()) { + const previous = this.availability.find( + (entry) => entry.companyId.equals(company) && entry.definitionId.equals(id) + ); + const value = active === null ? Maybe.none() : Maybe.some(active); + const changed = previous + ? previous.update(value) + : CatalogAvailability.create(company, id, value); + if (previous === changed || (!previous && active === null)) { + return Result.ok(this); + } + return Result.ok( + new AssetCatalog( + { + ...this.props, + availability: previous + ? this.availability.map((entry) => (entry.id.equals(previous.id) ? changed : entry)) + : [...this.availability, changed], + }, + this.id + ) + ); + } + if (active === null) { + return Result.fail(new AssetCatalogError("INVALID_REQUEST")); + } + const changed = item.setActive(active); + if (changed === item) { + return Result.ok(this); + } + return Result.ok( + new AssetCatalog( + { + ...this.props, + definitions: this.definitions.map((entry) => (entry.id.equals(id) ? changed : entry)), + }, + this.id + ) + ); + } +} +function sameScope(a: Maybe, b: Maybe) { + return a.getOrUndefined()?.toPrimitive() === b.getOrUndefined()?.toPrimitive(); +} diff --git a/modules/assets/src/api/domain/asset-catalog.test.ts b/modules/assets/src/api/domain/asset-catalog.test.ts new file mode 100644 index 0000000..5423449 --- /dev/null +++ b/modules/assets/src/api/domain/asset-catalog.test.ts @@ -0,0 +1,137 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { UniqueID } from "@repo/rdx-ddd"; +import { Maybe } from "@repo/rdx-utils"; + +import { AssetCatalog } from "./asset-catalog.aggregate"; +import { seedAssetCatalog } from "./asset-catalog-seed"; +import type { AssetDefinitionSpec } from "./asset-definition"; +import { AssetDefinitionValues } from "./value-objects/asset-definition-values"; + +const global = Maybe.none(); +function values(spec: AssetDefinitionSpec) { + const result = AssetDefinitionValues.create(spec); + assert.ok(result.isSuccess); + return result.data; +} +function field(name: string) { + return values({ kind: "field", name, type: "text", unit: "", choices: [], description: "" }); +} +function changed(result: ReturnType) { + assert.ok(result.isSuccess); + return result.data; +} +test("el catálogo inicial es idempotente, con UUID propios y características opcionales", () => { + const catalog = seedAssetCatalog(AssetCatalog.create()); + assert.equal(catalog.definitions.length, 42); + assert.equal(seedAssetCatalog(catalog), catalog); + const ids = [ + catalog.id, + ...catalog.definitions.map((item) => item.id), + ...catalog.definitions.flatMap((item) => item.bindings.map((binding) => binding.id)), + ].map((id) => id.toPrimitive()); + assert.equal(new Set(ids).size, ids.length); + assert.ok(ids.every((id) => id.charAt(14) === "7")); + const templates = catalog.definitions.filter((item) => item.values.data.kind === "template"); + assert.equal(templates.length, 12); + assert.ok(templates.every((item) => item.bindings.every((field) => !field.required))); + const rehydrated = AssetCatalog.rehydrate( + { definitions: catalog.definitions, availability: catalog.availability }, + catalog.id + ); + assert.ok(rehydrated.id.equals(catalog.id)); + assert.ok(rehydrated.definitions[0]?.id.equals(catalog.definitions[0]?.id)); + const stock = catalog.definitions[0]; + assert.ok(stock); + const protectedResult = catalog.save(global, Maybe.some(stock.id), field("Cambio")); + assert.ok(!protectedResult.isSuccess); + assert.equal(protectedResult.error.code, "BUILTIN_PROTECTED"); +}); +test("la disponibilidad por compañía conserva el global y los campos desactivados", () => { + let catalog = seedAssetCatalog(AssetCatalog.create()); + const first = Maybe.some(UniqueID.generateNewID()); + const second = Maybe.some(UniqueID.generateNewID()); + const stock = catalog.definitions[0]; + assert.ok(stock); + catalog = changed(catalog.setActive(first, stock.id, false)); + assert.equal(catalog.active(stock, first), false); + assert.equal(catalog.active(stock, second), true); + assert.equal(stock.active, true); + assert.equal(catalog.definitions.length, 42); + const overrideId = catalog.availability[0]?.id; + assert.ok(overrideId); + assert.equal(changed(catalog.setActive(first, stock.id, false)), catalog); + catalog = changed(catalog.setActive(first, stock.id, null)); + assert.equal(catalog.active(stock, first), true); + assert.ok(catalog.availability[0]?.id.equals(overrideId)); + catalog = changed(catalog.setActive(global, stock.id, false)); + assert.equal(seedAssetCatalog(catalog), catalog); + assert.equal(catalog.active(catalog.definitions[0] ?? stock, first), false); +}); +test("las plantillas validan ámbito, campos activos e identidad estable al editar", () => { + let catalog = seedAssetCatalog(AssetCatalog.create()); + const company = Maybe.some(UniqueID.generateNewID()); + const other = Maybe.some(UniqueID.generateNewID()); + catalog = changed(catalog.save(company, Maybe.none(), field("Campo local"))); + const local = catalog.definitions.at(-1); + assert.ok(local); + const template = values({ + kind: "template", + name: "Plantilla local", + description: "", + fields: [{ fieldId: local.id.toPrimitive(), required: false }], + }); + assert.ok(!catalog.save(global, Maybe.none(), template).isSuccess); + assert.ok(!catalog.save(other, Maybe.none(), template).isSuccess); + catalog = changed(catalog.save(company, Maybe.none(), template)); + const saved = catalog.definitions.at(-1); + assert.ok(saved); + assert.equal(changed(catalog.save(company, Maybe.some(saved.id), template)), catalog); + const updated = values({ ...template.data, name: "Otra denominación" }); + catalog = changed(catalog.save(company, Maybe.some(saved.id), updated)); + const edited = catalog.definitions.at(-1); + assert.ok(edited); + assert.ok(edited.id.equals(saved.id)); + assert.ok(edited.bindings[0]?.id.equals(saved.bindings[0]?.id)); + catalog = changed(catalog.setActive(company, local.id, false)); + assert.ok( + !catalog.save(company, Maybe.none(), values({ ...template.data, name: "Nueva" })).isSuccess + ); + assert.ok(catalog.save(company, Maybe.some(saved.id), updated).isSuccess); + assert.ok(!catalog.visible(other).some((item) => item.id.equals(local.id))); +}); +test("los campos validan listas, igualdad semántica, duplicados y tipo fijo", () => { + const list: AssetDefinitionSpec = { + kind: "field", + name: "Batería", + description: "", + type: "choice", + unit: "", + choices: ["Buena", "Sustituir"], + }; + assert.ok(!AssetDefinitionValues.create({ ...list, choices: [] }).isSuccess); + assert.ok(!AssetDefinitionValues.create({ ...list, choices: ["Buena", " buena "] }).isSuccess); + assert.ok(!AssetDefinitionValues.create({ ...list, type: "text" }).isSuccess); + const frozen = values(list); + assert.ok(Object.isFrozen(frozen.data)); + if (frozen.data.kind === "field") { + assert.ok(Object.isFrozen(frozen.data.choices)); + } + let catalog = AssetCatalog.create(); + catalog = changed(catalog.save(global, Maybe.none(), frozen)); + const saved = catalog.definitions[0]; + assert.ok(saved); + assert.ok(!catalog.save(global, Maybe.none(), values({ ...list, name: " batería " })).isSuccess); + assert.ok(!catalog.save(global, Maybe.some(saved.id), field("Batería")).isSuccess); + assert.ok( + !catalog.save(global, Maybe.some(saved.id), values({ ...list, choices: ["Buena"] })).isSuccess + ); + assert.ok( + catalog.save( + global, + Maybe.some(saved.id), + values({ ...list, choices: [...list.choices, "Desconocida"] }) + ).isSuccess + ); +}); diff --git a/modules/assets/src/api/domain/asset-definition.entity.ts b/modules/assets/src/api/domain/asset-definition.entity.ts new file mode 100644 index 0000000..34ed6a0 --- /dev/null +++ b/modules/assets/src/api/domain/asset-definition.entity.ts @@ -0,0 +1,131 @@ +import { DomainEntity, type UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { AssetCatalogError } from "./asset-definition"; +import type { AssetDefinitionValues } from "./value-objects/asset-definition-values"; + +export class TemplateField extends DomainEntity<{ fieldId: UniqueID; required: boolean }> { + static create(fieldId: UniqueID, required: boolean, id?: UniqueID) { + return new TemplateField({ fieldId, required }, id); + } + static rehydrate(fieldId: UniqueID, required: boolean, id: UniqueID) { + return new TemplateField({ fieldId, required }, id); + } + get fieldId() { + return this.props.fieldId; + } + get required() { + return this.props.required; + } +} +type DefinitionProps = { + companyId: Maybe; + stockKey: Maybe; + values: AssetDefinitionValues; + active: boolean; + bindings: readonly TemplateField[]; +}; +export class AssetDefinition extends DomainEntity { + private constructor(props: DefinitionProps, id?: UniqueID) { + super({ ...props, bindings: Object.freeze([...props.bindings]) }, id); + } + static create( + companyId: Maybe, + values: AssetDefinitionValues, + active: boolean, + stockKey = Maybe.none() + ) { + const bindings = values.fieldIds.map((id, index) => + TemplateField.create( + id, + values.data.kind === "template" && (values.data.fields[index]?.required ?? false) + ) + ); + return new AssetDefinition({ companyId, stockKey, values, active, bindings }); + } + static rehydrate(props: DefinitionProps, id: UniqueID) { + return new AssetDefinition(props, id); + } + get companyId() { + return this.props.companyId; + } + get stockKey() { + return this.props.stockKey; + } + get values() { + return this.props.values; + } + get active() { + return this.props.active; + } + get bindings() { + return this.props.bindings; + } + update(values: AssetDefinitionValues): Result { + if (this.stockKey.getOrUndefined()) { + return Result.fail(new AssetCatalogError("BUILTIN_PROTECTED")); + } + const previous = this.values.data; + const next = values.data; + if ( + previous.kind !== next.kind || + (previous.kind === "field" && + next.kind === "field" && + (previous.type !== next.type || + previous.choices.some((choice) => !next.choices.includes(choice)))) + ) { + return Result.fail(new AssetCatalogError("FIELD_TYPE_FIXED")); + } + if (this.values.equals(values)) { + return Result.ok(this); + } + const bindings = values.fieldIds.map((fieldId, index) => + TemplateField.create( + fieldId, + next.kind === "template" && (next.fields[index]?.required ?? false), + this.bindings.find((field) => field.fieldId.equals(fieldId))?.id + ) + ); + return Result.ok(new AssetDefinition({ ...this.props, values, bindings }, this.id)); + } + setActive(active: boolean) { + return active === this.active ? this : new AssetDefinition({ ...this.props, active }, this.id); + } +} + +export class CatalogAvailability extends DomainEntity<{ + companyId: UniqueID; + definitionId: UniqueID; + active: Maybe; +}> { + static create( + companyId: UniqueID, + definitionId: UniqueID, + active: Maybe, + id?: UniqueID + ) { + return new CatalogAvailability({ companyId, definitionId, active }, id); + } + static rehydrate( + companyId: UniqueID, + definitionId: UniqueID, + active: Maybe, + id: UniqueID + ) { + return new CatalogAvailability({ companyId, definitionId, active }, id); + } + get companyId() { + return this.props.companyId; + } + get definitionId() { + return this.props.definitionId; + } + get active() { + return this.props.active; + } + update(active: Maybe) { + return active.getOrUndefined() === this.active.getOrUndefined() + ? this + : CatalogAvailability.create(this.companyId, this.definitionId, active, this.id); + } +} diff --git a/modules/assets/src/api/domain/asset-definition.ts b/modules/assets/src/api/domain/asset-definition.ts new file mode 100644 index 0000000..ee40a1b --- /dev/null +++ b/modules/assets/src/api/domain/asset-definition.ts @@ -0,0 +1,28 @@ +import { z } from "zod/v4"; + +const title = z.string().min(1).max(100); +export const FieldTypeSchema = z.enum(["text", "number", "date", "boolean", "choice"]); +export const TemplateFieldSchema = z.strictObject({ fieldId: z.uuid(), required: z.boolean() }); +export const AssetDefinitionSchema = z.discriminatedUnion("kind", [ + z.strictObject({ + kind: z.literal("field"), + name: title, + description: z.string().max(1000), + type: FieldTypeSchema, + unit: z.string().max(30), + choices: z.array(title).max(100), + }), + z.strictObject({ + kind: z.literal("template"), + name: title, + description: z.string().max(1000), + fields: z.array(TemplateFieldSchema).min(1).max(100), + }), +]); +export type AssetDefinitionSpec = z.infer; +export type AssetFieldType = z.infer; +export class AssetCatalogError extends Error { + constructor(public readonly code: string) { + super(code); + } +} diff --git a/modules/assets/src/api/domain/asset-revision.entity.ts b/modules/assets/src/api/domain/asset-revision.entity.ts new file mode 100644 index 0000000..10514ce --- /dev/null +++ b/modules/assets/src/api/domain/asset-revision.entity.ts @@ -0,0 +1,25 @@ +import { DomainEntity, type UniqueID } from "@repo/rdx-ddd"; + +import type { AssetValues } from "./value-objects/asset-values"; +export type AssetRevisionProps = Readonly<{ + values: AssetValues; + actorId: UniqueID; + recordedAt: string; +}>; +export class AssetRevision extends DomainEntity { + static create(props: AssetRevisionProps) { + return new AssetRevision(props); + } + static rehydrate(props: AssetRevisionProps, id: UniqueID) { + return new AssetRevision(props, id); + } + get values() { + return this.props.values; + } + get actorId() { + return this.props.actorId; + } + get recordedAt() { + return this.props.recordedAt; + } +} diff --git a/modules/assets/src/api/domain/asset-values.ts b/modules/assets/src/api/domain/asset-values.ts new file mode 100644 index 0000000..db51a59 --- /dev/null +++ b/modules/assets/src/api/domain/asset-values.ts @@ -0,0 +1,31 @@ +import { z } from "zod/v4"; + +import { FieldTypeSchema } from "./asset-definition"; + +export const AssetStatusSchema = z.enum(["available", "in_use", "maintenance", "retired"]); +export const AssetCharacteristicSchema = z.strictObject({ + fieldId: z.uuid(), + name: z.string().min(1).max(100), + type: FieldTypeSchema, + unit: z.string().max(30), + choices: z.array(z.string().min(1).max(100)), + required: z.boolean(), +}); +export const AssetValueSchema = z.union([z.string().max(10000), z.number().finite(), z.boolean()]); +export const AssetInputSchema = z.strictObject({ + name: z.string().min(1).max(100), + templateId: z.uuid(), + siteId: z.uuid().nullable(), + personId: z.uuid().nullable(), + status: AssetStatusSchema, + notes: z.string().max(10000), + characteristics: z.record(z.uuid(), AssetValueSchema), +}); +export const AssetSpecificationSchema = z.strictObject({ + templateId: z.uuid(), + templateName: z.string().min(1).max(100), + fields: z.array(AssetCharacteristicSchema).min(1).max(100), +}); +export type AssetInput = z.infer; +export type AssetSpecificationData = z.infer; +export type AssetCharacteristic = z.infer; diff --git a/modules/assets/src/api/domain/asset.aggregate.ts b/modules/assets/src/api/domain/asset.aggregate.ts new file mode 100644 index 0000000..d0112c2 --- /dev/null +++ b/modules/assets/src/api/domain/asset.aggregate.ts @@ -0,0 +1,70 @@ +import { AggregateRoot, type UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { AssetCatalogError } from "./asset-definition"; +import { AssetRevision } from "./asset-revision.entity"; +import type { AssetSpecification, AssetValues } from "./value-objects/asset-values"; + +type AssetProps = Readonly<{ + companyId: UniqueID; + specification: AssetSpecification; + values: AssetValues; + history: readonly AssetRevision[]; +}>; +export class Asset extends AggregateRoot { + private constructor(props: AssetProps, id?: UniqueID) { + super({ ...props, history: Object.freeze([...props.history]) }, id); + } + static create( + companyId: UniqueID, + specification: AssetSpecification, + values: AssetValues, + actorId: UniqueID, + recordedAt: string + ) { + return Result.ok( + new Asset({ + companyId, + specification, + values, + history: [AssetRevision.create({ values, actorId, recordedAt })], + }) + ); + } + static rehydrate(props: AssetProps, id: UniqueID) { + return new Asset(props, id); + } + get companyId() { + return this.props.companyId; + } + get specification() { + return this.props.specification; + } + get values() { + return this.props.values; + } + get history() { + return this.props.history; + } + update( + values: AssetValues, + actorId: UniqueID, + recordedAt: string + ): Result { + if (values.toPrimitive().templateId !== this.values.toPrimitive().templateId) { + return Result.fail(new AssetCatalogError("TEMPLATE_FIXED")); + } + return Result.ok( + this.values.equals(values) + ? this + : new Asset( + { + ...this.props, + values, + history: [...this.history, AssetRevision.create({ values, actorId, recordedAt })], + }, + this.id + ) + ); + } +} diff --git a/modules/assets/src/api/domain/asset.test.ts b/modules/assets/src/api/domain/asset.test.ts new file mode 100644 index 0000000..566e268 --- /dev/null +++ b/modules/assets/src/api/domain/asset.test.ts @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { UniqueID } from "@repo/rdx-ddd"; + +import { Asset } from "./asset.aggregate"; +import type { AssetInput } from "./asset-values"; +import { AssetSpecification, AssetValues } from "./value-objects/asset-values"; + +const company = UniqueID.generateNewID(); +const actor = UniqueID.generateNewID(); +const templateId = UniqueID.generateNewID().toPrimitive(); +const ids = Array.from({ length: 5 }, () => UniqueID.generateNewID().toPrimitive()); +function specification(required = false) { + const types = ["text", "number", "date", "boolean", "choice"] as const; + const result = AssetSpecification.create({ + templateId, + templateName: "Portátil", + fields: ids.map((fieldId, index) => ({ + fieldId, + name: types[index] ?? "text", + type: types[index] ?? "text", + unit: "", + choices: index === 4 ? ["A", "B"] : [], + required: required && index === 3, + })), + }); + assert.ok(result.isSuccess); + return result.data; +} +function input(characteristics: AssetInput["characteristics"] = {}): AssetInput { + return { + name: " Equipo ", + templateId, + siteId: null, + personId: null, + status: "available", + notes: "", + characteristics, + }; +} +test("Características opcionales, tipos y límites; cero y falso son valores presentes", () => { + const spec = specification(); + assert.ok(AssetValues.create(input(), spec).isSuccess); + const typed = Object.fromEntries( + ids.map((id, index) => [id, ["texto", 0, "2026-02-28", false, "A"][index] ?? ""]) + ); + const valid = AssetValues.create(input(typed), spec); + assert.ok(valid.isSuccess); + assert.equal(valid.data.toPrimitive().name, "Equipo"); + for (const [index, value] of [ + [1, "12"], + [2, "2026-02-30"], + [3, "false"], + [4, "C"], + ] as const) { + const id = ids[index]; + assert.ok(id); + const failed = AssetValues.create(input({ [id]: value }), spec); + assert.equal(failed.isSuccess, false); + } + assert.equal( + AssetValues.create(input({ [UniqueID.generateNewID().toPrimitive()]: "extra" }), spec) + .isSuccess, + false + ); + const required = specification(true); + assert.equal(AssetValues.create(input(), required).isSuccess, false); + const boolean = ids[3]; + assert.ok(boolean); + assert.ok(AssetValues.create(input({ [boolean]: false }), required).isSuccess); + assert.equal( + AssetValues.create(input({ [boolean]: false, [ids[0] ?? ""]: "x".repeat(10001) }), spec) + .isSuccess, + false + ); +}); +test("Activo conserva UUID y revisiones; los cambios iguales son idempotentes y retirado no admite asignación", () => { + const spec = specification(); + const first = AssetValues.create(input(), spec); + assert.ok(first.isSuccess); + const created = Asset.create(company, spec, first.data, actor, "2026-10-04T12:00:00.000Z"); + assert.ok(created.isSuccess); + const asset = created.data; + assert.equal(asset.id.toPrimitive().charAt(14), "7"); + assert.equal(asset.history[0]?.id.toPrimitive().charAt(14), "7"); + const same = asset.update(first.data, actor, "2026-10-04T13:00:00.000Z"); + assert.ok(same.isSuccess); + assert.equal(same.data, asset); + const next = AssetValues.create( + { ...input(), personId: UniqueID.generateNewID().toPrimitive(), status: "in_use" }, + spec + ); + assert.ok(next.isSuccess); + const changed = asset.update(next.data, actor, "2026-10-04T13:00:00.000Z"); + assert.ok(changed.isSuccess); + assert.ok(changed.data.id.equals(asset.id)); + assert.equal(changed.data.history.length, 2); + assert.equal(changed.data.history[0]?.values.toPrimitive().personId, null); + assert.equal( + AssetValues.create({ ...next.data.toPrimitive(), status: "retired" }, spec).isSuccess, + false + ); + assert.ok(Object.isFrozen(next.data.toPrimitive().characteristics)); +}); diff --git a/modules/assets/src/api/domain/value-objects/asset-definition-values.ts b/modules/assets/src/api/domain/value-objects/asset-definition-values.ts new file mode 100644 index 0000000..272b13f --- /dev/null +++ b/modules/assets/src/api/domain/value-objects/asset-definition-values.ts @@ -0,0 +1,95 @@ +import { Name, UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import type { AssetDefinitionSpec } from "../asset-definition"; +import { AssetCatalogError, AssetDefinitionSchema } from "../asset-definition"; + +/** Definición inmutable con nombre y referencias reconstruidos como Value Objects. */ +export class AssetDefinitionValues { + private constructor( + public readonly data: Readonly, + public readonly name: Name, + public readonly fieldIds: readonly UniqueID[] + ) { + Object.freeze(this); + } + static create(input: AssetDefinitionSpec): Result { + const parsed = AssetDefinitionSchema.safeParse(input); + const name = Name.create(input.name, 100); + if (!(parsed.success && name.isSuccess)) { + return Result.fail(new AssetCatalogError("INVALID_REQUEST")); + } + const data = parsed.data; + if (data.kind === "field") { + const choices = data.choices.map((value) => value.trim()); + if ( + (data.type === "choice" && !choices.length) || + (data.type !== "choice" && choices.length) || + choices.some((choice) => !choice) || + new Set(choices.map((choice) => choice.toLocaleLowerCase("es"))).size !== choices.length + ) { + return Result.fail(new AssetCatalogError("INVALID_CHOICES")); + } + const values = { + ...data, + name: name.data.toPrimitive(), + description: data.description.trim(), + unit: data.unit.trim(), + choices, + }; + Object.freeze(values.choices); + Object.freeze(values); + return Result.ok(new AssetDefinitionValues(values, name.data, Object.freeze([]))); + } + const ids: UniqueID[] = []; + for (const field of data.fields) { + const id = UniqueID.create(field.fieldId); + if (!id.isSuccess || ids.some((previous) => previous.equals(id.data))) { + return Result.fail(new AssetCatalogError("INVALID_FIELDS")); + } + ids.push(id.data); + } + const fields = data.fields.map((field, index) => ({ + fieldId: ids[index]?.toPrimitive() ?? field.fieldId, + required: field.required, + })); + fields.forEach(Object.freeze); + Object.freeze(fields); + return Result.ok( + new AssetDefinitionValues( + Object.freeze({ + ...data, + name: name.data.toPrimitive(), + description: data.description.trim(), + fields, + }), + name.data, + Object.freeze(ids) + ) + ); + } + equals(other: AssetDefinitionValues): boolean { + const a = this.data; + const b = other.data; + if (a.kind !== b.kind || a.name !== b.name || a.description !== b.description) { + return false; + } + if (a.kind === "field" && b.kind === "field") { + return ( + a.type === b.type && + a.unit === b.unit && + a.choices.length === b.choices.length && + a.choices.every((choice, index) => choice === b.choices[index]) + ); + } + return ( + a.kind === "template" && + b.kind === "template" && + a.fields.length === b.fields.length && + a.fields.every( + (field, index) => + field.fieldId === b.fields[index]?.fieldId && field.required === b.fields[index]?.required + ) + ); + } +} diff --git a/modules/assets/src/api/domain/value-objects/asset-values.ts b/modules/assets/src/api/domain/value-objects/asset-values.ts new file mode 100644 index 0000000..0e931bd --- /dev/null +++ b/modules/assets/src/api/domain/value-objects/asset-values.ts @@ -0,0 +1,200 @@ +import { Name, UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; +import { z } from "zod/v4"; + +import { AssetCatalogError } from "../asset-definition"; +import type { AssetCharacteristic, AssetInput, AssetSpecificationData } from "../asset-values"; +import { AssetInputSchema, AssetSpecificationSchema } from "../asset-values"; + +export class AssetSpecification { + private constructor( + private readonly data: AssetSpecificationData, + public readonly templateId: UniqueID, + public readonly name: Name + ) { + Object.freeze(this); + } + toPrimitive() { + return this.data; + } + static create(input: AssetSpecificationData): Result { + const parsed = AssetSpecificationSchema.safeParse(input); + if ( + !parsed.success || + new Set(input.fields.map((field) => field.fieldId.toLowerCase())).size !== input.fields.length + ) { + return Result.fail(new AssetCatalogError("INVALID_REQUEST")); + } + const template = UniqueID.create(input.templateId); + const name = Name.create(input.templateName, 100); + if (!(template.isSuccess && name.isSuccess)) { + return Result.fail(new AssetCatalogError("INVALID_REQUEST")); + } + const frozenFields = input.fields.map((field) => { + const choices = [...field.choices]; + Object.freeze(choices); + return Object.freeze({ ...field, fieldId: field.fieldId.toLowerCase(), choices }); + }); + Object.freeze(frozenFields); + return Result.ok( + new AssetSpecification( + Object.freeze({ + templateId: template.data.toPrimitive(), + templateName: name.data.toPrimitive(), + fields: frozenFields, + }), + template.data, + name.data + ) + ); + } + equals(other: AssetSpecification | undefined) { + if (!(other instanceof AssetSpecification)) { + return false; + } + const first = this.toPrimitive(); + const second = other.toPrimitive(); + return ( + first.templateId === second.templateId && + first.templateName === second.templateName && + first.fields.length === second.fields.length && + first.fields.every((field, index) => { + const candidate = second.fields[index]; + return ( + candidate !== undefined && + field.fieldId === candidate.fieldId && + field.name === candidate.name && + field.type === candidate.type && + field.unit === candidate.unit && + field.required === candidate.required && + field.choices.length === candidate.choices.length && + field.choices.every((choice, position) => choice === candidate.choices[position]) + ); + }) + ); + } +} +function validCharacteristic(field: AssetCharacteristic, value: unknown) { + switch (field.type) { + case "text": + return typeof value === "string"; + case "number": + return typeof value === "number" && Number.isFinite(value); + case "boolean": + return typeof value === "boolean"; + case "choice": + return typeof value === "string" && field.choices.includes(value); + case "date": + return z.iso.date().safeParse(value).success; + } +} +export class AssetValues { + private constructor( + private readonly data: AssetInput, + public readonly name: Name, + public readonly siteId: Maybe, + public readonly personId: Maybe + ) { + Object.freeze(this); + } + toPrimitive() { + return this.data; + } + static create( + input: AssetInput, + specification: AssetSpecification + ): Result { + const parsed = AssetInputSchema.safeParse(input); + const name = Name.create(input.name, 100); + if ( + !(parsed.success && name.isSuccess) || + input.templateId.toLowerCase() !== specification.toPrimitive().templateId + ) { + return Result.fail(new AssetCatalogError("INVALID_REQUEST")); + } + const data = parsed.data; + const site = optionalIdentity(data.siteId); + const person = optionalIdentity(data.personId); + const parsedCharacteristics = normalizeCharacteristics(data.characteristics, specification); + if (!parsedCharacteristics.isSuccess) { + return parsedCharacteristics; + } + const characteristics = parsedCharacteristics.data; + if (data.status === "retired" && data.personId !== null) { + return Result.fail(new AssetCatalogError("RETIRED_ASSIGNED")); + } + return Result.ok( + new AssetValues( + Object.freeze({ + ...data, + name: name.data.toPrimitive(), + templateId: data.templateId.toLowerCase(), + siteId: data.siteId?.toLowerCase() ?? null, + personId: data.personId?.toLowerCase() ?? null, + notes: data.notes.trim(), + characteristics: Object.freeze(characteristics), + }), + name.data, + site, + person + ) + ); + } + equals(other: AssetValues | undefined) { + if (!(other instanceof AssetValues)) { + return false; + } + const first = this.toPrimitive(); + const second = other.toPrimitive(); + return ( + first.name === second.name && + first.templateId === second.templateId && + first.siteId === second.siteId && + first.personId === second.personId && + first.status === second.status && + first.notes === second.notes && + Object.keys(first.characteristics).length === Object.keys(second.characteristics).length && + Object.entries(first.characteristics).every( + ([key, value]) => value === second.characteristics[key] + ) + ); + } +} + +function normalizeCharacteristics( + input: AssetInput["characteristics"], + specification: AssetSpecification +): Result { + const characteristics: AssetInput["characteristics"] = {}; + for (const [key, raw] of Object.entries(input)) { + const field = specification + .toPrimitive() + .fields.find((item) => item.fieldId === key.toLowerCase()); + const value = typeof raw === "string" ? raw.trim() : raw; + if (!field || key.toLowerCase() in characteristics || !validCharacteristic(field, value)) { + return Result.fail(new AssetCatalogError("INVALID_CHARACTERISTICS")); + } + if (value !== "") { + characteristics[key.toLowerCase()] = value; + } + } + if ( + specification + .toPrimitive() + .fields.some((field) => field.required && !(field.fieldId in characteristics)) + ) { + return Result.fail(new AssetCatalogError("REQUIRED_CHARACTERISTICS")); + } + return Result.ok(characteristics); +} + +function optionalIdentity(value: string | null): Maybe { + if (value === null) { + return Maybe.none(); + } + const parsed = UniqueID.create(value); + if (!parsed.isSuccess) { + throw new Error("Referencia validada inconsistente."); + } + return Maybe.some(parsed.data); +} diff --git a/modules/assets/src/api/infrastructure/http/asset-catalog.routes.ts b/modules/assets/src/api/infrastructure/http/asset-catalog.routes.ts new file mode 100644 index 0000000..23e09b9 --- /dev/null +++ b/modules/assets/src/api/infrastructure/http/asset-catalog.routes.ts @@ -0,0 +1,109 @@ +import type { Result } from "@repo/rdx-utils"; +import type { Application, RequestHandler, Response } from "express"; +import { Router } from "express"; +import { z } from "zod/v4"; + +import { + AvailabilityRequestSchema, + CatalogItemParamsSchema, + CatalogParamsSchema, + SaveDefinitionRequestSchema, +} from "../../../common"; +import type { AssetCatalogUseCases } from "../../application/asset-catalog.use-cases"; +import { catalogScope } from "../../application/asset-catalog.use-cases"; +import { buildCatalogResponse } from "../../application/asset-catalog-response.builder"; +import type { AssetCatalog } from "../../domain/asset-catalog.aggregate"; +import type { AssetCatalogError } from "../../domain/asset-definition"; + +function sendError(response: Response, code: string) { + const status = code === "INVALID_REQUEST" ? 400 : code.endsWith("NOT_FOUND") ? 404 : 409; + response.status(status).json({ code, requestId: response.getHeader("x-request-id") }); +} +function sendResult( + response: Response, + result: Result, + scope: string, + status = 200, + savedName?: { id?: string | undefined; name: string; kind: string } +) { + if (!result.isSuccess) { + sendError(response, result.error.code); + return; + } + const parsed = catalogScope(scope); + if (!parsed.isSuccess) { + sendError(response, "INVALID_REQUEST"); + return; + } + const body = buildCatalogResponse(result.data, parsed.data); + if (!savedName) { + response.status(status).json(body); + return; + } + const savedId = + savedName.id?.toLowerCase() ?? + body.items.find( + (item) => + item.companyId === (scope === "global" ? null : scope.toLowerCase()) && + item.values.kind === savedName.kind && + item.values.name === savedName.name.trim() + )?.id; + if (!savedId) { + throw new Error("Resultado del catálogo inconsistente."); + } + response.status(status).json({ ...body, savedId }); +} +export function registerAssetCatalogRoutes( + app: Application, + useCases: AssetCatalogUseCases, + authenticate: RequestHandler +) { + const router = Router(); + router.get("/:scope", (request, response, next) => { + const params = CatalogParamsSchema.safeParse(request.params); + if (!(params.success && z.strictObject({}).safeParse(request.query).success)) { + sendError(response, "INVALID_REQUEST"); + return; + } + useCases + .get(params.data.scope) + .then((result) => sendResult(response, result, params.data.scope)) + .catch(next); + }); + for (const path of ["/:scope", "/:scope/:id/update"]) { + router.post(path, (request, response, next) => { + const schema = path.endsWith("update") ? CatalogItemParamsSchema : CatalogParamsSchema; + const params = schema.safeParse(request.params); + const body = SaveDefinitionRequestSchema.safeParse(request.body); + if (!(params.success && body.success)) { + sendError(response, "INVALID_REQUEST"); + return; + } + const identity = + "id" in params.data && typeof params.data.id === "string" ? params.data.id : undefined; + useCases + .save(params.data.scope, identity, body.data.values) + .then((result) => + sendResult(response, result, params.data.scope, identity ? 200 : 201, { + id: identity, + name: body.data.values.name, + kind: body.data.values.kind, + }) + ) + .catch(next); + }); + } + router.post("/:scope/:id/status", (request, response, next) => { + const params = CatalogItemParamsSchema.safeParse(request.params); + const body = AvailabilityRequestSchema.safeParse(request.body); + if (!(params.success && body.success)) { + sendError(response, "INVALID_REQUEST"); + return; + } + useCases + .setActive(params.data.scope, params.data.id, body.data.active) + .then((result) => sendResult(response, result, params.data.scope)) + .catch(next); + }); + app.use("/api/asset-catalog", authenticate, router); +} diff --git a/modules/assets/src/api/infrastructure/http/asset.routes.ts b/modules/assets/src/api/infrastructure/http/asset.routes.ts new file mode 100644 index 0000000..9521b1f --- /dev/null +++ b/modules/assets/src/api/infrastructure/http/asset.routes.ts @@ -0,0 +1,75 @@ +import type { Application, RequestHandler, Response } from "express"; +import { Router } from "express"; +import { z } from "zod/v4"; + +import { + AssetParamsSchema, + InventoryParamsSchema, + SaveAssetRequestSchema, +} from "../../../common/inventory"; +import type { AssetUseCases } from "../../application/asset.use-cases"; +import { buildAssetResponse } from "../../application/asset-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 registerAssetRoutes( + app: Application, + useCases: AssetUseCases, + authenticate: RequestHandler +) { + const router = Router(); + router.get("/:companyId", (request, response, next) => { + const params = InventoryParamsSchema.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({ + assets: result.data.assets.map((asset) => + buildAssetResponse(asset, result.data.actorNames) + ), + }); + }) + .catch(next); + }); + for (const path of ["/:companyId", "/:companyId/:id/update"]) { + router.post(path, (request, response, next) => { + const params = ( + path.endsWith("update") ? AssetParamsSchema : InventoryParamsSchema + ).safeParse(request.params); + const body = SaveAssetRequestSchema.safeParse(request.body); + const actor = z + .object({ userId: z.uuid() }) + .safeParse("session" in request ? request.session : undefined); + if (!(params.success && body.success && actor.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({ asset: buildAssetResponse(result.data.asset, result.data.actorNames) }); + }) + .catch(next); + }); + } + app.use("/api/assets", authenticate, router); +} diff --git a/modules/assets/src/api/infrastructure/persistence/asset-catalog-persistence.mapper.ts b/modules/assets/src/api/infrastructure/persistence/asset-catalog-persistence.mapper.ts new file mode 100644 index 0000000..36c8396 --- /dev/null +++ b/modules/assets/src/api/infrastructure/persistence/asset-catalog-persistence.mapper.ts @@ -0,0 +1,133 @@ +import { UniqueID } from "@repo/rdx-ddd"; +import { Maybe } from "@repo/rdx-utils"; + +import { AssetCatalog } from "../../domain/asset-catalog.aggregate"; +import { AssetDefinitionSchema } from "../../domain/asset-definition"; +import { + AssetDefinition, + CatalogAvailability, + TemplateField, +} from "../../domain/asset-definition.entity"; +import { AssetDefinitionValues } from "../../domain/value-objects/asset-definition-values"; + +import type { + AvailabilityModel, + BindingModel, + CatalogModel, + DefinitionModel, +} from "./asset-catalog.model"; + +function identity(value: string) { + const parsed = UniqueID.create(value); + if (!parsed.isSuccess) { + throw new Error("Identidad de catálogo persistida inconsistente."); + } + return parsed.data; +} +export class AssetCatalogPersistenceMapper { + toDomain( + root: CatalogModel, + rows: readonly DefinitionModel[], + fields: readonly BindingModel[], + overrides: readonly AvailabilityModel[] + ) { + const definitions = rows.map((row) => { + const spec = AssetDefinitionSchema.safeParse(row.values); + if (!spec.success) { + throw new Error("Definición de activo persistida inconsistente."); + } + const values = AssetDefinitionValues.create(spec.data); + if (!values.isSuccess) { + throw new Error("Valores de catálogo persistidos inconsistentes."); + } + const bindings = fields + .filter((field) => field.templateId === row.id) + .sort((a, b) => a.position - b.position) + .map((field) => + TemplateField.rehydrate(identity(field.fieldId), field.required, identity(field.id)) + ); + if ( + bindings.length !== values.data.fieldIds.length || + bindings.some( + (binding, index) => + !binding.fieldId.equals(values.data.fieldIds[index]) || + (values.data.data.kind === "template" && + binding.required !== values.data.data.fields[index]?.required) + ) + ) { + throw new Error("Referencias de plantilla persistidas inconsistentes."); + } + return AssetDefinition.rehydrate( + { + values: values.data, + active: row.active, + companyId: row.companyId ? Maybe.some(identity(row.companyId)) : Maybe.none(), + stockKey: row.stockKey ? Maybe.some(row.stockKey) : Maybe.none(), + bindings, + }, + identity(row.id) + ); + }); + const availability = overrides.map((row) => + CatalogAvailability.rehydrate( + identity(row.companyId), + identity(row.definitionId), + row.active === null ? Maybe.none() : Maybe.some(row.active), + identity(row.id) + ) + ); + const catalog = AssetCatalog.rehydrate({ definitions, availability }, identity(root.id)); + validateReferences(catalog); + return catalog; + } + toPersistence(catalog: AssetCatalog) { + return { + definitions: catalog.definitions.map((item) => ({ + id: item.id.toPrimitive(), + catalogId: catalog.id.toPrimitive(), + companyId: item.companyId.getOrUndefined()?.toPrimitive() ?? null, + stockKey: item.stockKey.getOrUndefined() ?? null, + values: item.values.data, + active: item.active, + })), + bindings: catalog.definitions.flatMap((item) => + item.bindings.map((field, position) => ({ + id: field.id.toPrimitive(), + templateId: item.id.toPrimitive(), + fieldId: field.fieldId.toPrimitive(), + required: field.required, + position, + })) + ), + availability: catalog.availability.map((item) => ({ + id: item.id.toPrimitive(), + catalogId: catalog.id.toPrimitive(), + companyId: item.companyId.toPrimitive(), + definitionId: item.definitionId.toPrimitive(), + active: item.active.getOrUndefined() ?? null, + })), + }; + } +} + +function validateReferences(catalog: AssetCatalog) { + for (const item of catalog.definitions) { + if (item.stockKey.getOrUndefined() && item.companyId.getOrUndefined()) { + throw new Error("Ámbito de definición inicial persistido inconsistente."); + } + for (const fieldId of item.values.fieldIds) { + const field = catalog + .visible(item.companyId) + .find((candidate) => candidate.id.equals(fieldId)); + if (!field || field.values.data.kind !== "field") { + throw new Error("Ámbito de campo persistido inconsistente."); + } + } + } + for (const override of catalog.availability) { + const definition = catalog.definitions.find((item) => item.id.equals(override.definitionId)); + if (!definition || definition.companyId.getOrUndefined()) { + throw new Error("Disponibilidad de catálogo persistida inconsistente."); + } + } +} diff --git a/modules/assets/src/api/infrastructure/persistence/asset-catalog.model.ts b/modules/assets/src/api/infrastructure/persistence/asset-catalog.model.ts new file mode 100644 index 0000000..2f44bdd --- /dev/null +++ b/modules/assets/src/api/infrastructure/persistence/asset-catalog.model.ts @@ -0,0 +1,121 @@ +import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize"; +import { DataTypes, Model, QueryTypes } from "sequelize"; +export class CatalogModel extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: string; + declare catalogKey: string; +} +export class DefinitionModel extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: string; + declare catalogId: string; + declare companyId: string | null; + declare stockKey: string | null; + declare values: unknown; + declare active: boolean; +} +export class BindingModel extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: string; + declare templateId: string; + declare fieldId: string; + declare required: boolean; + declare position: number; +} +export class AvailabilityModel extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: string; + declare catalogId: string; + declare companyId: string; + declare definitionId: string; + declare active: boolean | null; +} +const id = { type: DataTypes.UUID, primaryKey: true }; +const fk = (model: string, nullable = false) => ({ + type: DataTypes.UUID, + allowNull: nullable, + references: { model, key: "id" }, + onDelete: "RESTRICT", +}); +export async function migrateAssetCatalog(database: Sequelize) { + const query = database.getQueryInterface(); + const tables = await database.query<{ name: string }>( + "SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()", + { type: QueryTypes.SELECT } + ); + const create = async (name: string, columns: Parameters[1]) => { + if (!tables.some((table) => table.name === name)) { + await query.createTable(name, columns); + } + }; + const catalog = { + id, + catalogKey: { type: DataTypes.STRING(20), unique: true, allowNull: false }, + }; + const definition = { + id, + catalogId: fk("asset_catalogs"), + companyId: fk("companies", true), + stockKey: { type: DataTypes.STRING(100), allowNull: true, unique: true }, + values: { type: DataTypes.JSON, allowNull: false }, + active: { type: DataTypes.BOOLEAN, allowNull: false }, + }; + const binding = { + id, + templateId: fk("asset_definitions"), + fieldId: fk("asset_definitions"), + required: { type: DataTypes.BOOLEAN, allowNull: false }, + position: { type: DataTypes.INTEGER, allowNull: false }, + }; + const availability = { + id, + catalogId: fk("asset_catalogs"), + companyId: fk("companies"), + definitionId: fk("asset_definitions"), + active: { type: DataTypes.BOOLEAN, allowNull: true }, + }; + await create("asset_catalogs", catalog); + await create("asset_definitions", definition); + await create("asset_template_fields", binding); + await create("asset_availability", availability); + for (const [table, fields, name] of [ + ["asset_template_fields", ["templateId", "fieldId"], "asset_template_field_unique"], + ["asset_availability", ["companyId", "definitionId"], "asset_company_availability_unique"], + ] as const) { + const indexes = await database.query<{ name: string }>( + "SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=:table", + { replacements: { table }, type: QueryTypes.SELECT } + ); + if (!indexes.some((index) => index.name === name)) { + await query.addIndex(table, [...fields], { unique: true, name }); + } + } + CatalogModel.init(catalog, { + sequelize: database, + tableName: "asset_catalogs", + timestamps: false, + }); + DefinitionModel.init(definition, { + sequelize: database, + tableName: "asset_definitions", + timestamps: false, + }); + BindingModel.init(binding, { + sequelize: database, + tableName: "asset_template_fields", + timestamps: false, + }); + AvailabilityModel.init(availability, { + sequelize: database, + tableName: "asset_availability", + timestamps: false, + }); +} diff --git a/modules/assets/src/api/infrastructure/persistence/asset-persistence.mapper.ts b/modules/assets/src/api/infrastructure/persistence/asset-persistence.mapper.ts new file mode 100644 index 0000000..7dfbae7 --- /dev/null +++ b/modules/assets/src/api/infrastructure/persistence/asset-persistence.mapper.ts @@ -0,0 +1,103 @@ +import { UniqueID } from "@repo/rdx-ddd"; +import { z } from "zod/v4"; + +import { Asset } from "../../domain/asset.aggregate"; +import { AssetRevision } from "../../domain/asset-revision.entity"; +import { AssetInputSchema, AssetSpecificationSchema } from "../../domain/asset-values"; +import { AssetSpecification, AssetValues } from "../../domain/value-objects/asset-values"; + +import type { AssetModel, AssetRevisionModel } from "./asset.model"; +export function inventoryIdentity(value: string) { + const parsed = UniqueID.create(value); + if (!parsed.isSuccess) { + throw new Error("Identidad de inventario persistida inconsistente."); + } + return parsed.data; +} +export class AssetPersistenceMapper { + toDomain(row: AssetModel, revisions: readonly AssetRevisionModel[]) { + const specification = AssetSpecification.create( + AssetSpecificationSchema.parse(row.specification) + ); + if (!specification.isSuccess) { + throw new Error("Especificación de activo persistida inconsistente."); + } + const values = AssetValues.create(AssetInputSchema.parse(row.values), specification.data); + if (!values.isSuccess) { + throw new Error("Valores de activo persistidos inconsistentes."); + } + const current = values.data.toPrimitive(); + if ( + row.templateId !== current.templateId || + row.siteId !== current.siteId || + row.personId !== current.personId + ) { + throw new Error("Referencias de activo persistidas inconsistentes."); + } + const history = [...revisions] + .sort((a, b) => a.position - b.position) + .map((revision, position) => { + const previous = AssetValues.create( + AssetInputSchema.parse(revision.values), + specification.data + ); + if ( + !previous.isSuccess || + revision.position !== position || + !z.iso.datetime().safeParse(revision.recordedAt).success + ) { + throw new Error("Historial de activo persistido inconsistente."); + } + if ( + revision.siteId !== previous.data.toPrimitive().siteId || + revision.personId !== previous.data.toPrimitive().personId + ) { + throw new Error("Referencias de revisión persistidas inconsistentes."); + } + return AssetRevision.rehydrate( + { + values: previous.data, + actorId: inventoryIdentity(revision.actorId), + recordedAt: revision.recordedAt, + }, + inventoryIdentity(revision.id) + ); + }); + if (!(history.length && history.at(-1)?.values.equals(values.data))) { + throw new Error("Última revisión de activo inconsistente."); + } + return Asset.rehydrate( + { + companyId: inventoryIdentity(row.companyId), + specification: specification.data, + values: values.data, + history, + }, + inventoryIdentity(row.id) + ); + } + toPersistence(asset: Asset) { + const values = asset.values.toPrimitive(); + return { + asset: { + id: asset.id.toPrimitive(), + companyId: asset.companyId.toPrimitive(), + templateId: values.templateId, + siteId: values.siteId, + personId: values.personId, + specification: asset.specification.toPrimitive(), + values, + }, + revisions: asset.history.map((revision, position) => ({ + id: revision.id.toPrimitive(), + assetId: asset.id.toPrimitive(), + actorId: revision.actorId.toPrimitive(), + siteId: revision.values.toPrimitive().siteId, + personId: revision.values.toPrimitive().personId, + recordedAt: revision.recordedAt, + values: revision.values.toPrimitive(), + position, + })), + }; + } +} diff --git a/modules/assets/src/api/infrastructure/persistence/asset.model.ts b/modules/assets/src/api/infrastructure/persistence/asset.model.ts new file mode 100644 index 0000000..9127934 --- /dev/null +++ b/modules/assets/src/api/infrastructure/persistence/asset.model.ts @@ -0,0 +1,89 @@ +import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize"; +import { DataTypes, Model, QueryTypes } from "sequelize"; +export class AssetModel extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: string; + declare companyId: string; + declare templateId: string; + declare siteId: string | null; + declare personId: string | null; + declare specification: unknown; + declare values: unknown; +} +export class AssetRevisionModel extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: string; + declare assetId: string; + declare actorId: string; + declare recordedAt: string; + declare values: unknown; + declare position: number; + declare siteId: string | null; + declare personId: string | null; +} +const id = { type: DataTypes.UUID, primaryKey: true }; +const fk = (model: string, nullable = false) => ({ + type: DataTypes.UUID, + allowNull: nullable, + references: { model, key: "id" }, + onDelete: "RESTRICT", +}); +export async function migrateInventory(database: Sequelize) { + const query = database.getQueryInterface(); + const tables = await database.query<{ name: string }>( + "SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()", + { type: QueryTypes.SELECT } + ); + const asset = { + id, + companyId: fk("companies"), + templateId: fk("asset_definitions"), + siteId: fk("company_sites", true), + personId: fk("company_contacts", true), + specification: { type: DataTypes.JSON, allowNull: false }, + values: { type: DataTypes.JSON, allowNull: false }, + }; + const revision = { + id, + assetId: fk("assets"), + actorId: fk("users"), + siteId: fk("company_sites", true), + personId: fk("company_contacts", true), + recordedAt: { type: DataTypes.STRING(30), allowNull: false }, + values: { type: DataTypes.JSON, allowNull: false }, + position: { type: DataTypes.INTEGER, allowNull: false }, + }; + if (!tables.some((table) => table.name === "assets")) { + await query.createTable("assets", asset); + } + if (!tables.some((table) => table.name === "asset_revisions")) { + await query.createTable("asset_revisions", revision); + } + const revisionColumns = await query.describeTable("asset_revisions"); + for (const [column, reference] of [ + ["siteId", "company_sites"], + ["personId", "company_contacts"], + ] as const) { + if (!(column in revisionColumns)) { + await query.addColumn("asset_revisions", column, fk(reference, true)); + } + // Reconstruye las referencias de instalaciones que ya hubieran registrado revisiones. + const source = + column === "siteId" + ? "UPDATE asset_revisions SET siteId = NULLIF(JSON_UNQUOTE(JSON_EXTRACT(`values`, '$.siteId')), 'null')" + : "UPDATE asset_revisions SET personId = NULLIF(JSON_UNQUOTE(JSON_EXTRACT(`values`, '$.personId')), 'null')"; + await database.query( + source + (column === "siteId" ? " WHERE siteId IS NULL" : " WHERE personId IS NULL") + ); + } + AssetModel.init(asset, { sequelize: database, tableName: "assets", timestamps: false }); + AssetRevisionModel.init(revision, { + sequelize: database, + tableName: "asset_revisions", + timestamps: false, + }); +} diff --git a/modules/assets/src/api/infrastructure/persistence/sequelize-asset-catalog.repository.ts b/modules/assets/src/api/infrastructure/persistence/sequelize-asset-catalog.repository.ts new file mode 100644 index 0000000..c56e9b3 --- /dev/null +++ b/modules/assets/src/api/infrastructure/persistence/sequelize-asset-catalog.repository.ts @@ -0,0 +1,95 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import type { Maybe } from "@repo/rdx-utils"; +import { Result } from "@repo/rdx-utils"; +import type { Sequelize, Transaction } from "sequelize"; +import { Op, QueryTypes } from "sequelize"; + +import type { IAssetCatalogRepository } from "../../application/asset-catalog.repository"; +import { AssetCatalogError } from "../../domain/asset-definition"; + +import { + AvailabilityModel, + BindingModel, + CatalogModel, + DefinitionModel, +} from "./asset-catalog.model"; +import type { AssetCatalogPersistenceMapper } from "./asset-catalog-persistence.mapper"; + +export class SequelizeAssetCatalogRepository implements IAssetCatalogRepository { + constructor( + private readonly database: Sequelize, + private readonly mapper: AssetCatalogPersistenceMapper + ) {} + private async load(scope: Maybe, transaction: Transaction, write: boolean) { + // Un bloqueo global serializa cambios de definiciones, referencias y disponibilidad. + const root = await CatalogModel.findOne({ + where: { catalogKey: "global" }, + transaction, + ...(write ? { lock: transaction.LOCK.UPDATE } : {}), + }); + if (!root) { + throw new Error("Catálogo de activos no inicializado."); + } + const company = scope.getOrUndefined(); + if (company) { + const rows = await this.database.query<{ id: string }>( + "SELECT id FROM companies WHERE id=:id", + { replacements: { id: company.toPrimitive() }, type: QueryTypes.SELECT, transaction } + ); + if (!rows.length) { + return Result.fail(new AssetCatalogError("COMPANY_NOT_FOUND")); + } + } + const definitions = await DefinitionModel.findAll({ + where: { catalogId: root.id }, + transaction, + }); + const bindings = definitions.length + ? await BindingModel.findAll({ + where: { templateId: definitions.map((item) => item.id) }, + transaction, + }) + : []; + const overrides = await AvailabilityModel.findAll({ + where: { catalogId: root.id }, + transaction, + }); + return Result.ok(this.mapper.toDomain(root, definitions, bindings, overrides)); + } + get(scope: Maybe) { + return this.database.transaction((transaction) => this.load(scope, transaction, false)); + } + update(scope: Maybe, action: Parameters[1]) { + return this.database.transaction(async (transaction) => { + const loaded = await this.load(scope, transaction, true); + if (!loaded.isSuccess) { + return loaded; + } + const changed = action(loaded.data); + if (!changed.isSuccess || changed.data === loaded.data) { + return changed; + } + const rows = this.mapper.toPersistence(changed.data); + // Primero las definiciones, después sus relaciones: las FK no admiten huérfanos. + for (const row of rows.definitions) { + await DefinitionModel.upsert(row, { transaction }); + } + const previousBindings = loaded.data.definitions.flatMap((item) => item.bindings); + const retained = new Set(rows.bindings.map((row) => row.id)); + const removed = previousBindings.filter((field) => !retained.has(field.id.toPrimitive())); + if (removed.length) { + await BindingModel.destroy({ + where: { id: { [Op.in]: removed.map((field) => field.id.toPrimitive()) } }, + transaction, + }); + } + for (const row of rows.bindings) { + await BindingModel.upsert(row, { transaction }); + } + for (const row of rows.availability) { + await AvailabilityModel.upsert(row, { transaction }); + } + return changed; + }); + } +} diff --git a/modules/assets/src/api/infrastructure/persistence/sequelize-asset.repository.ts b/modules/assets/src/api/infrastructure/persistence/sequelize-asset.repository.ts new file mode 100644 index 0000000..14ff851 --- /dev/null +++ b/modules/assets/src/api/infrastructure/persistence/sequelize-asset.repository.ts @@ -0,0 +1,147 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import type { Sequelize, Transaction } from "sequelize"; +import { QueryTypes } from "sequelize"; + +import type { IAssetRepository, InventoryContext } from "../../application/asset.repository"; +import { AssetCatalogError } from "../../domain/asset-definition"; + +import { AssetModel, AssetRevisionModel } from "./asset.model"; +import { + AvailabilityModel, + BindingModel, + CatalogModel, + DefinitionModel, +} from "./asset-catalog.model"; +import { AssetCatalogPersistenceMapper } from "./asset-catalog-persistence.mapper"; +import { AssetPersistenceMapper, inventoryIdentity } from "./asset-persistence.mapper"; +export class SequelizeAssetRepository implements IAssetRepository { + private readonly mapper = new AssetPersistenceMapper(); + constructor(private readonly database: Sequelize) {} + private async load( + companyId: UniqueID, + transaction: Transaction, + write: boolean + ): Promise> { + // Orden fijo de bloqueos: catálogo antes de compañía, compartido con las escrituras del catálogo. + const root = await CatalogModel.findOne({ + where: { catalogKey: "global" }, + transaction, + ...(write ? { lock: transaction.LOCK.UPDATE } : {}), + }); + if (!root) { + throw new Error("Catálogo no inicializado."); + } + const replacements = { id: companyId.toPrimitive() }; + const companies = await this.database.query<{ id: string }>( + `SELECT id FROM companies WHERE id=:id${write ? " FOR UPDATE" : ""}`, + { replacements, type: QueryTypes.SELECT, transaction } + ); + if (!companies.length) { + return Result.fail(new AssetCatalogError("COMPANY_NOT_FOUND")); + } + const definitions = await DefinitionModel.findAll({ + where: { catalogId: root.id }, + transaction, + }); + const bindings = definitions.length + ? await BindingModel.findAll({ + where: { templateId: definitions.map((item) => item.id) }, + transaction, + }) + : []; + const overrides = await AvailabilityModel.findAll({ + where: { catalogId: root.id }, + transaction, + }); + const catalog = new AssetCatalogPersistenceMapper().toDomain( + root, + definitions, + bindings, + overrides + ); + const rows = await AssetModel.findAll({ + where: { companyId: companyId.toPrimitive() }, + transaction, + }); + const revisions = rows.length + ? await AssetRevisionModel.findAll({ + where: { assetId: rows.map((item) => item.id) }, + transaction, + }) + : []; + const sites = await this.database.query<{ id: string }>( + "SELECT id FROM company_sites WHERE companyId=:id", + { replacements, type: QueryTypes.SELECT, transaction } + ); + const people = await this.database.query<{ id: string; active: boolean }>( + "SELECT id, active FROM company_contacts WHERE companyId=:id", + { replacements, type: QueryTypes.SELECT, transaction } + ); + const actors = await this.database.query<{ id: string; name: string }>( + "SELECT id, name FROM users", + { type: QueryTypes.SELECT, transaction } + ); + const assets = rows.map((row) => { + const asset = this.mapper.toDomain( + row, + revisions.filter((revision) => revision.assetId === row.id) + ); + const spec = asset.specification.toPrimitive(); + const template = definitions.find((item) => item.id === spec.templateId); + if ( + !template || + (template.companyId !== null && template.companyId !== companyId.toPrimitive()) + ) { + throw new Error("Plantilla de activo fuera de su compañía."); + } + for (const revision of asset.history) { + const values = revision.values.toPrimitive(); + if ( + (values.siteId && !sites.some((site) => site.id === values.siteId)) || + (values.personId && !people.some((person) => person.id === values.personId)) + ) { + throw new Error("Asignación de inventario fuera de su compañía."); + } + } + return asset; + }); + return Result.ok({ + catalog, + assets, + sites: sites.map((site) => inventoryIdentity(site.id)), + people: people.map((person) => ({ + id: inventoryIdentity(person.id), + active: Boolean(person.active), + })), + actorNames: new Map(actors.map((actor) => [actor.id, actor.name])), + }); + } + get(companyId: UniqueID) { + return this.database.transaction((transaction) => this.load(companyId, transaction, false)); + } + save(companyId: UniqueID, action: Parameters[1]) { + return this.database.transaction(async (transaction) => { + const loaded = await this.load(companyId, transaction, true); + if (!loaded.isSuccess) { + return Result.fail(loaded.error); + } + const changed = action(loaded.data); + if (!changed.isSuccess) { + return Result.fail(changed.error); + } + const previous = loaded.data.assets.find((asset) => asset.id.equals(changed.data.id)); + if (changed.data !== previous) { + const rows = this.mapper.toPersistence(changed.data); + await AssetModel.upsert(rows.asset, { transaction }); + const existing = new Set(previous?.history.map((revision) => revision.id.toPrimitive())); + for (const revision of rows.revisions) { + if (!existing.has(revision.id)) { + await AssetRevisionModel.create(revision, { transaction }); + } + } + } + return Result.ok({ asset: changed.data, actorNames: loaded.data.actorNames }); + }); + } +} diff --git a/modules/assets/src/api/module.ts b/modules/assets/src/api/module.ts new file mode 100644 index 0000000..58c6157 --- /dev/null +++ b/modules/assets/src/api/module.ts @@ -0,0 +1,44 @@ +import { UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; +import type { RequestHandler } from "express"; +import type { Sequelize } from "sequelize"; + +import { AssetUseCases } from "./application/asset.use-cases"; +import { AssetCatalogUseCases } from "./application/asset-catalog.use-cases"; +import { seedAssetCatalog } from "./domain/asset-catalog-seed"; +import { registerAssetRoutes } from "./infrastructure/http/asset.routes"; +import { registerAssetCatalogRoutes } from "./infrastructure/http/asset-catalog.routes"; +import { migrateInventory } from "./infrastructure/persistence/asset.model"; +import { + CatalogModel, + migrateAssetCatalog, +} from "./infrastructure/persistence/asset-catalog.model"; +import { AssetCatalogPersistenceMapper } from "./infrastructure/persistence/asset-catalog-persistence.mapper"; +import { SequelizeAssetRepository } from "./infrastructure/persistence/sequelize-asset.repository"; +import { SequelizeAssetCatalogRepository } from "./infrastructure/persistence/sequelize-asset-catalog.repository"; +export async function createAssetModule(database: Sequelize, authenticate: RequestHandler) { + await migrateAssetCatalog(database); + await CatalogModel.findOrCreate({ + where: { catalogKey: "global" }, + defaults: { id: UniqueID.generateNewID().toPrimitive(), catalogKey: "global" }, + }); + const repository = new SequelizeAssetCatalogRepository( + database, + new AssetCatalogPersistenceMapper() + ); + const seeded = await repository.update(Maybe.none(), (catalog) => + Result.ok(seedAssetCatalog(catalog)) + ); + if (!seeded.isSuccess) { + throw new Error("No se pudo inicializar el catálogo de activos."); + } + await migrateInventory(database); + const inventory = new AssetUseCases(new SequelizeAssetRepository(database)); + const useCases = new AssetCatalogUseCases(repository); + return { + register: (app: Parameters[0]) => { + registerAssetCatalogRoutes(app, useCases, authenticate); + registerAssetRoutes(app, inventory, authenticate); + }, + }; +} diff --git a/modules/assets/src/common/index.ts b/modules/assets/src/common/index.ts new file mode 100644 index 0000000..17e0732 --- /dev/null +++ b/modules/assets/src/common/index.ts @@ -0,0 +1,30 @@ +import { z } from "zod/v4"; + +import { AssetDefinitionSchema, FieldTypeSchema } from "../api/domain/asset-definition"; + +export type { AssetDefinitionSpec, AssetFieldType } from "../api/domain/asset-definition"; + +export { AssetDefinitionSchema, FieldTypeSchema }; +export const CatalogParamsSchema = z.strictObject({ + scope: z.union([z.literal("global"), z.uuid()]), +}); +export const CatalogItemParamsSchema = CatalogParamsSchema.extend({ id: z.uuid() }); +export const SaveDefinitionRequestSchema = z.strictObject({ values: AssetDefinitionSchema }); +export const AvailabilityRequestSchema = z.strictObject({ active: z.boolean().nullable() }); +export const CatalogItemSchema = z.strictObject({ + id: z.uuid(), + companyId: z.uuid().nullable(), + builtin: z.boolean(), + active: z.boolean(), + effectiveActive: z.boolean(), + overridden: z.boolean(), + values: AssetDefinitionSchema, + bindings: z.array(z.strictObject({ id: z.uuid(), fieldId: z.uuid(), required: z.boolean() })), +}); +export const CatalogResponseSchema = z.strictObject({ items: z.array(CatalogItemSchema) }); +export const SaveDefinitionResponseSchema = CatalogResponseSchema.extend({ savedId: z.uuid() }); +export type CatalogItemDTO = z.infer; +export type CatalogResponseDTO = z.infer; +export type SaveDefinitionResponseDTO = z.infer; + +export * from "./inventory"; diff --git a/modules/assets/src/common/inventory.ts b/modules/assets/src/common/inventory.ts new file mode 100644 index 0000000..d034092 --- /dev/null +++ b/modules/assets/src/common/inventory.ts @@ -0,0 +1,33 @@ +import { z } from "zod/v4"; + +import { + AssetInputSchema, + AssetSpecificationSchema, + AssetStatusSchema, + AssetValueSchema, +} from "../api/domain/asset-values"; + +export type { AssetCharacteristic, AssetInput } from "../api/domain/asset-values"; + +export { AssetInputSchema, AssetSpecificationSchema, AssetStatusSchema, AssetValueSchema }; +export const InventoryParamsSchema = z.strictObject({ companyId: z.uuid() }); +export const AssetParamsSchema = InventoryParamsSchema.extend({ id: z.uuid() }); +export const SaveAssetRequestSchema = z.strictObject({ values: AssetInputSchema }); +export const AssetRevisionSchema = z.strictObject({ + id: z.uuid(), + values: AssetInputSchema, + actorId: z.uuid(), + actorName: z.string().min(1), + recordedAt: z.iso.datetime(), +}); +export const AssetSchema = z.strictObject({ + id: z.uuid(), + companyId: z.uuid(), + specification: AssetSpecificationSchema, + values: AssetInputSchema, + history: z.array(AssetRevisionSchema).min(1), +}); +export const InventoryResponseSchema = z.strictObject({ assets: z.array(AssetSchema) }); +export const AssetResponseSchema = z.strictObject({ asset: AssetSchema }); +export type AssetDTO = z.infer; +export type AssetResponseDTO = z.infer; diff --git a/modules/assets/src/web/asset-characteristic-control.tsx b/modules/assets/src/web/asset-characteristic-control.tsx new file mode 100644 index 0000000..bac645d --- /dev/null +++ b/modules/assets/src/web/asset-characteristic-control.tsx @@ -0,0 +1,85 @@ +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 { useTranslation } from "react-i18next"; + +import type { AssetCharacteristic, AssetInput } from "../common/inventory"; + +type CharacteristicValue = AssetInput["characteristics"][string]; +export function AssetCharacteristicControl({ + field, + value, + onChange, + prefix, + disabled, +}: { + field: AssetCharacteristic; + value: CharacteristicValue | undefined; + onChange: (value: CharacteristicValue | undefined) => void; + prefix: string; + disabled: boolean; +}) { + const { t } = useTranslation("assets"); + const id = `${prefix}-${field.fieldId}`; + const label = `${field.name}${field.unit ? ` (${field.unit})` : ""}${field.required ? " *" : ""}`; + return ( +
+ + {field.type === "choice" || field.type === "boolean" ? ( + + onChange( + choice === "" ? undefined : field.type === "boolean" ? choice === "true" : choice + ) + } + options={ + field.type === "boolean" + ? [ + { value: "true", label: t("yes") }, + { value: "false", label: t("no") }, + ] + : field.choices.map((choice) => ({ value: choice, label: choice })) + } + placeholder={t("noValue")} + required={field.required} + value={value === undefined ? "" : String(value)} + /> + ) : ( + { + const input = event.currentTarget.value; + onChange(input === "" ? undefined : field.type === "number" ? Number(input) : input); + }} + onInput={ + field.type === "date" + ? (event) => { + const input = event.currentTarget.value; + onChange(input || undefined); + } + : undefined + } + required={field.required} + step={field.type === "number" ? "any" : undefined} + type={inputType(field.type)} + value={value === undefined ? "" : String(value)} + /> + )} +
+ ); +} + +function inputType(type: AssetCharacteristic["type"]) { + if (type === "number") { + return "number"; + } + if (type === "date") { + return "date"; + } + return "text"; +} diff --git a/modules/assets/src/web/asset-detail.tsx b/modules/assets/src/web/asset-detail.tsx new file mode 100644 index 0000000..b0b14e9 --- /dev/null +++ b/modules/assets/src/web/asset-detail.tsx @@ -0,0 +1,91 @@ +import { useTranslation } from "react-i18next"; + +import type { AssetDTO } from "../common/inventory"; + +import type { InventoryLookups } from "./asset-form"; +export function AssetDetail({ asset, lookups }: { asset: AssetDTO; lookups: InventoryLookups }) { + const { t, i18n } = useTranslation("assets"); + const nameOf = (id: string | null, options: readonly { id: string; name: string }[]) => + id ? (options.find((option) => option.id === id)?.name ?? id) : t("noAssignment"); + const display = (value: string | number | boolean | undefined) => + value === undefined + ? t("noValue") + : typeof value === "boolean" + ? t(value ? "yes" : "no") + : String(value); + return ( +
+

{t("snapshotNotice")}

+
+
+
{t("template")}
+
{asset.specification.templateName}
+
+
+
{t("status")}
+
{t(asset.values.status)}
+
+
+
{t("site")}
+
{nameOf(asset.values.siteId, lookups.sites)}
+
+
+
{t("person")}
+
{nameOf(asset.values.personId, lookups.people)}
+
+ {asset.specification.fields.map((field) => ( +
+
+ {field.name} + {field.unit ? ` (${field.unit})` : ""} +
+
+ {display(asset.values.characteristics[field.fieldId])} +
+
+ ))} +
+ {asset.values.notes && ( +

{asset.values.notes}

+ )} +

+ {t("history")} ({asset.history.length}) +

+
    + {[...asset.history].reverse().map((revision) => ( +
  1. +

    + {new Intl.DateTimeFormat(i18n.language, { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(revision.recordedAt))}{" "} + · {t("recordedBy", { name: revision.actorName })} +

    +

    + {revision.values.name} · {t(revision.values.status)} +

    +

    + {t("site")}: {nameOf(revision.values.siteId, lookups.sites)} · {t("person")}:{" "} + {nameOf(revision.values.personId, lookups.people)} +

    +
    + {t("characteristics")} +
    + {asset.specification.fields.map((field) => ( +
    +
    {field.name}
    +
    {display(revision.values.characteristics[field.fieldId])}
    +
    + ))} +
    + {revision.values.notes && ( +

    {revision.values.notes}

    + )} +
    +
  2. + ))} +
+

ID: {asset.id}

+
+ ); +} diff --git a/modules/assets/src/web/asset-form.tsx b/modules/assets/src/web/asset-form.tsx new file mode 100644 index 0000000..4fa85d7 --- /dev/null +++ b/modules/assets/src/web/asset-form.tsx @@ -0,0 +1,292 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button } from "@repo/shadcn-ui/components/button"; +import { ContextHelp } from "@repo/shadcn-ui/components/context-help"; +import { FormActions, FormDialog } from "@repo/shadcn-ui/components/form-dialog"; +import { Input } from "@repo/shadcn-ui/components/input"; +import { Label } from "@repo/shadcn-ui/components/label"; +import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action"; +import { Textarea } from "@repo/shadcn-ui/components/textarea"; +import { useMutation, 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 { CatalogItemDTO } from "../common"; +import type { AssetDTO, AssetInput } from "../common/inventory"; +import { AssetInputSchema, AssetStatusSchema } from "../common/inventory"; + +import { AssetCharacteristicControl } from "./asset-characteristic-control"; +import { CatalogTemplateForm } from "./catalog-template-form"; +import { InventoryApiError, inventoryApi, inventoryKeys } from "./inventory-api"; +export type InventoryLookups = { + sites: readonly { id: string; name: string }[]; + people: readonly { id: string; name: string; active: boolean }[]; + onCreateSite: (onCreated: (id: string) => void) => void; + onCreatePerson: (onCreated: (id: string) => void) => void; +}; +export function InventoryFeedback({ error }: { error: Error | null }) { + const { t } = useTranslation("assets"); + return error ? ( +

+ {t(`inventoryErrors.${error instanceof InventoryApiError ? error.code : "INTERNAL_ERROR"}`)} +

+ ) : null; +} +export function AssetForm({ + companyId, + csrfToken, + asset, + items, + lookups, + onSaved, +}: { + companyId: string; + csrfToken: string; + asset?: AssetDTO | undefined; + items: readonly CatalogItemDTO[]; + lookups: InventoryLookups; + onSaved: () => void; +}) { + const { t } = useTranslation("assets"); + const prefix = useId(); + const client = useQueryClient(); + const [creatingTemplate, setCreatingTemplate] = useState(false); + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + const form = useForm({ + resolver: zodResolver(AssetInputSchema), + defaultValues: asset?.values ?? { + name: "", + templateId: "", + siteId: null, + personId: null, + status: "available", + notes: "", + characteristics: {}, + }, + }); + const values = form.watch(); + const templates = items.filter((item) => item.values.kind === "template" && item.effectiveActive); + const template = templates.find((item) => item.id === values.templateId); + const fields = + asset?.specification.fields ?? + (template?.values.kind === "template" + ? template.values.fields.flatMap((binding) => { + const field = items.find((item) => item.id === binding.fieldId); + return field?.values.kind === "field" + ? [ + { + fieldId: field.id, + name: field.values.name, + type: field.values.type, + unit: field.values.unit, + choices: field.values.choices, + required: binding.required, + }, + ] + : []; + }) + : []); + const mutation = useMutation({ + mutationFn: (input: AssetInput) => inventoryApi.save(companyId, input, csrfToken, asset?.id), + onSuccess: async () => { + await client.invalidateQueries({ queryKey: inventoryKeys.company(companyId) }); + if (mounted.current) { + onSaved(); + } + }, + }); + const setCharacteristic = ( + fieldId: string, + value: AssetInput["characteristics"][string] | undefined + ) => { + const characteristics = { ...form.getValues("characteristics") }; + if (value === undefined) { + delete characteristics[fieldId]; + } else { + characteristics[fieldId] = value; + } + form.setValue("characteristics", characteristics, { shouldDirty: true }); + }; + const selectProps = { disabled: mutation.isPending, placeholder: t("noAssignment") }; + return ( + <> +
mutation.mutate(input))}> +
+
+ + +
+
+
+ + setCreatingTemplate(true) } + } + disabled={!!asset || mutation.isPending} + id={`${prefix}-template`} + label={t("template")} + onValueChange={(templateId) => { + form.setValue("templateId", templateId); + form.setValue("characteristics", {}); + }} + options={ + asset + ? [{ value: asset.values.templateId, label: asset.specification.templateName }] + : templates.map((item) => ({ value: item.id, label: item.values.name })) + } + placeholder={t("chooseValue")} + required + value={values.templateId} + /> +
+
+ + { + const parsed = AssetStatusSchema.safeParse(value); + if (parsed.success) { + form.setValue("status", parsed.data); + } + }} + options={AssetStatusSchema.options.map((value) => ({ value, label: t(value) }))} + placeholder={t("chooseValue")} + required + value={values.status} + /> +
+
+ + + lookups.onCreateSite((id) => { + if (mounted.current) { + form.setValue("siteId", id); + } + }), + }} + id={`${prefix}-site`} + label={t("site")} + onValueChange={(id) => form.setValue("siteId", id || null)} + options={lookups.sites.map((site) => ({ value: site.id, label: site.name }))} + value={values.siteId ?? ""} + /> +
+
+ + + lookups.onCreatePerson((id) => { + if (mounted.current) { + form.setValue("personId", id); + } + }), + }} + id={`${prefix}-person`} + label={t("person")} + onValueChange={(id) => form.setValue("personId", id || null)} + options={lookups.people + .filter((person) => person.active || person.id === asset?.values.personId) + .map((person) => ({ + value: person.id, + label: person.name, + disabled: !person.active && person.id !== values.personId, + }))} + value={values.personId ?? ""} + /> +
+
+ {fields.length > 0 && ( +
+

{t("characteristics")}

+
+ {fields.map((field) => ( + setCharacteristic(field.fieldId, value)} + prefix={prefix} + value={values.characteristics[field.fieldId]} + /> + ))} +
+
+ )} +
+ +