diff --git a/apps/server/src/resource-lifecycle.integration.ts b/apps/server/src/resource-lifecycle.integration.ts new file mode 100644 index 0000000..32aafc7 --- /dev/null +++ b/apps/server/src/resource-lifecycle.integration.ts @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; + +import type { ResourceInput } from "@metis/resources/common"; +import { ResourceResponseSchema } from "@metis/resources/common"; +import type { Sequelize } from "sequelize"; +import { DataTypes, QueryTypes } from "sequelize"; +import { z } from "zod/v4"; + +import { UniqueID } from "../../../packages/rdx-ddd/src/index"; + +export async function verifyResourceLifecycle( + request: (path: string, body?: unknown, origin?: string, token?: string) => Promise, + companyId: string, + otherId: string, + database: Sequelize, + input: ResourceInput +) { + const base = `/resources/${companyId}`; + const createdResponse = await request(base, { + values: { ...input, name: "Recurso para ciclo de vida" }, + }); + + assert.equal(createdResponse.status, 201); + const created = ResourceResponseSchema.parse(await createdResponse.json()).resource; + const path = `${base}/${created.id}`; + + for (const [active, revisions] of [ + [false, 2], + [false, 2], + [true, 3], + ] as const) { + const response = await request(`${path}/status`, { active }); + + assert.equal(response.status, 200); + const changed = ResourceResponseSchema.parse(await response.json()).resource; + + assert.equal(changed.values.active, active); + assert.equal(changed.history.length, revisions); + assert.deepEqual(changed.values.characteristics, created.values.characteristics); + assert.deepEqual(changed.specification, created.specification); + } + assert.equal((await request(`${path}/status`, { active: "false" })).status, 400); + assert.equal((await request(`${path}/status`, { active: false, name: "Otra cosa" })).status, 400); + assert.equal((await request(`${path}/delete`, { unexpected: true })).status, 400); + assert.equal( + (await request(`/resources/${otherId}/${created.id}/status`, { active: false })).status, + 404 + ); + assert.equal((await request(`/resources/${otherId}/${created.id}/delete`, {})).status, 404); + assert.equal((await request(`${path}/delete`, {}, "https://other.example")).status, 403); + assert.equal( + (await request(`${path}/status`, { active: false }, undefined, "a".repeat(64))).status, + 403 + ); + const linksTable = `resource_lifecycle_${UniqueID.generateNewID().toPrimitive().replaceAll("-", "")}`; + + await database.getQueryInterface().createTable(linksTable, { + resourceId: { + type: DataTypes.UUID, + primaryKey: true, + references: { model: "resources", key: "id" }, + onDelete: "RESTRICT", + }, + }); + try { + await database.getQueryInterface().bulkInsert(linksTable, [{ resourceId: created.id }]); + const blocked = await request(`${path}/delete`, {}); + + assert.equal(blocked.status, 409); + assert.equal( + z.object({ code: z.string() }).parse(await blocked.json()).code, + "RESOURCE_IN_USE" + ); + assert.equal((await request(`${path}/status`, { active: false })).status, 200); + } finally { + await database.getQueryInterface().dropTable(linksTable); + } + const removed = await request(`${path}/delete`, {}); + + assert.equal(removed.status, 200); + assert.deepEqual(await removed.json(), { deleted: true }); + assert.equal((await request(`${path}/delete`, {})).status, 404); + assert.equal((await request(`${path}/status`, { active: true })).status, 404); + for (const table of [ + "resources", + "resource_revisions", + "resource_characteristics", + "resource_revision_characteristics", + "resource_specification_fields", + "resource_specification_field_choices", + "resource_specification_sections", + ]) { + const rows = await database.query( + `SELECT 1 FROM ${table} WHERE ${table === "resources" ? "id" : "resourceId"}=:id`, + { + replacements: { id: created.id }, + type: QueryTypes.SELECT, + } + ); + + assert.equal(rows.length, 0); + } +} diff --git a/apps/server/src/resources.integration.ts b/apps/server/src/resources.integration.ts index 77fcf25..e6a50bc 100644 --- a/apps/server/src/resources.integration.ts +++ b/apps/server/src/resources.integration.ts @@ -14,6 +14,8 @@ import { z } from "zod/v4"; import { UniqueID } from "../../../packages/rdx-ddd/src/index"; +import { verifyResourceLifecycle } from "./resource-lifecycle.integration"; + export async function verifyResources( request: (path: string, body?: unknown, origin?: string, token?: string) => Promise, companyId: string, @@ -462,4 +464,21 @@ export async function verifyResources( "INVALID_REQUEST", 400 ); + await verifyResourceLifecycle(request, companyId, otherId, database, first); + const mailbox = await resource(inventory, { + ...concurrentInput, + name: "Buzón para eliminar", + characteristics: { + ...concurrentInput.characteristics, + [stock.id]: "delete-mailbox@example.test", + }, + }); + + assert.equal((await request(`${inventory}/${mailbox.id}/delete`, {})).status, 200); + const projected = await database.query("SELECT id FROM mailboxes WHERE id=:id", { + replacements: { id: mailbox.id }, + type: QueryTypes.SELECT, + }); + + assert.equal(projected.length, 0); } diff --git a/docs/componentes-de-interfaz.md b/docs/componentes-de-interfaz.md index c70425e..fbd2ab7 100644 --- a/docs/componentes-de-interfaz.md +++ b/docs/componentes-de-interfaz.md @@ -632,7 +632,7 @@ El alta y la edición de plantillas de activos y recursos reutilizan `TemplateMe ## Títulos de grupos en diálogos -Todos los diálogos con grupos utilizan el estilo de «Datos de la plantilla»: texto pequeño, seminegrita, mayúsculas, espaciado entre letras y color atenuado (`text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-4`). `FormSectionTitle` centraliza esta presentación y recibe `children`, `id` opcional y `as="h3" | "h4" | "h5" | "legend" | "span"` para conservar la semántica del encabezado, leyenda o título dentro de un `summary`. El consumidor aporta el texto traducido; las mayúsculas se aplican mediante CSS. La variante `span` conserva la tipografía y deja el espaciado al `summary` que la contiene, sin margen inferior propio. +Todos los diálogos con grupos utilizan el estilo de «Datos de la plantilla»: texto pequeño, seminegrita, mayúsculas, espaciado entre letras y color atenuado (`text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-4`). `FormSectionTitle` centraliza esta presentación y recibe `children`, `id` opcional y `as="h3" | "h4" | "h5" | "legend" | "span"` para conservar la semántica del encabezado, leyenda o título dentro de un `summary`. El consumidor aporta el texto traducido; las mayúsculas se aplican mediante CSS. La variante `span` conserva la tipografía y deja el espaciado al `summary` que la contiene, sin margen inferior propio. Para cabeceras que comparten fila con contadores o acciones, `layout="inline"` elimina también el margen sin cambiar el nivel semántico (`h3`, `h4` o `h5`). El contenedor alinea los elementos con `items-center` y agrupa título y contador para mantenerlos juntos al adaptar el ancho. El modo `standalone` mantiene la separación inferior habitual; `span` utiliza `inline` por defecto. `FormSection`, `TemplateSectionsEditor` y `TemplateSectionFields` comparten este título, con independencia de sus bordes, acciones o contenido. La regla también se aplica a grupos de consulta, como los puestos del historial de perfiles. Los títulos principales del diálogo y los nombres editables de secciones mantienen su presentación propia. @@ -1026,3 +1026,21 @@ evitar un menú vacío. Mantener el lápiz para editar y el ojo para abrir o ver Los listados de configuraciones conservan deshabilitada la edición de registros retirados o nunca configurados. Las acciones de baja o eliminación siguen en el desplegable. Véase el [patrón de listados](patron-de-listados-de-entidades.md). + + +### Ciclo de vida de recursos + +El listado de Recursos mantiene Editar como acción principal de icono y ofrece +Ver, Activar/Desactivar y Eliminar definitivamente en el menú de tres puntos. +El cambio de estado conserva las asociaciones y añade una revisión; repetir el +mismo estado no crea revisiones adicionales. La mutación de estado envía solo +el estado solicitado para conservar posibles ediciones concurrentes. + +`DeleteResourceDialog`, independiente del listado, reutiliza `ConfirmDeleteDialog`, +identifica el recurso y advierte del borrado de su historial. Si el servidor devuelve +`RESOURCE_IN_USE`, muestra el motivo dentro del modal y permite cancelar. No se +eliminan asociaciones actuales ni históricas: solo se borran recursos sin referencias +externas, sus datos propios y sus proyecciones de buzón o acceso dentro de una transacción. +Tras borrar se limpia la selección afectada y se mantienen la búsqueda y la pestaña. +Las altas, ediciones y cambios de estado o borrado reutilizan +`resourceRelatedQueryKeys` para actualizar inventario y listados consumidores. diff --git a/docs/patron-de-dialogos.md b/docs/patron-de-dialogos.md index c956340..3c5c569 100644 --- a/docs/patron-de-dialogos.md +++ b/docs/patron-de-dialogos.md @@ -42,6 +42,12 @@ estable y la ayuda inmediatamente encima del pie del modal. espaciado entre letras y color atenuado (`text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-4`). Conservar el nivel semántico del encabezado; las mayúsculas pertenecen a la presentación CSS y los textos pasan por i18n. +- Cuando el título comparte fila con contadores o acciones, usar + `FormSectionTitle layout="inline"` y alinear la fila con `items-center`. + El título no añade margen inferior en este modo: la separación respecto al + contenido pertenece al contenedor. Mantener título y contador en un mismo + grupo flex para que no se separen al ajustar el ancho. Conservar el nivel + semántico del encabezado y reservar el margen habitual para títulos independientes. - El título principal del diálogo y los nombres editables de secciones tienen su propia función. El nombre editable de una sección va en su cabecera, junto a sus acciones. - Para los datos generales, preferir `FormSection variant="plain"`, sin borde ni relleno de diff --git a/modules/resources/package.json b/modules/resources/package.json index 856b723..ded8997 100644 --- a/modules/resources/package.json +++ b/modules/resources/package.json @@ -11,7 +11,7 @@ }, "scripts": { "typecheck": "tsc -p tsconfig.json", - "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/resource-catalog.test.ts src/api/domain/resource.test.ts src/web/catalog/shared/catalog.test.tsx src/web/resources/shared/resources.test.tsx src/web/resources/shared/resource-values.test.ts src/api/domain/template-sections.test.ts src/web/catalog-fields/select/catalog-field-selection.test.tsx" + "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/resource-catalog.test.ts src/api/domain/resource.test.ts src/api/application/resource-lifecycle.test.ts src/web/catalog/shared/catalog.test.tsx src/web/resources/shared/resources.test.tsx src/web/resources/shared/resource-values.test.ts src/api/domain/template-sections.test.ts src/web/catalog-fields/select/catalog-field-selection.test.tsx" }, "dependencies": { "@metis/core": "workspace:*", diff --git a/modules/resources/src/api/application/resource-lifecycle.test.ts b/modules/resources/src/api/application/resource-lifecycle.test.ts new file mode 100644 index 0000000..c7196fb --- /dev/null +++ b/modules/resources/src/api/application/resource-lifecycle.test.ts @@ -0,0 +1,162 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { Resource } from "../domain/resource.aggregate"; +import { ResourceCatalog } from "../domain/resource-catalog.aggregate"; +import { ResourceCatalogError } from "../domain/resource-definition"; +import { ResourceSpecification, ResourceValues } from "../domain/value-objects/resource-values.vo"; + +import type { IResourceRepository, InventoryContext } from "./resource.repository"; +import { ResourceUseCases } from "./resource.use-cases"; + +function fixture() { + const company = UniqueID.generateNewID(); + const actor = UniqueID.generateNewID(); + const templateId = UniqueID.generateNewID().toPrimitive(); + const fieldId = UniqueID.generateNewID().toPrimitive(); + const spec = ResourceSpecification.create({ + templateId, + templateName: "Aplicación", + fields: [{ fieldId, name: "Fabricante", type: "text", unit: "", choices: [], required: false }], + }); + + assert.ok(spec.isSuccess); + const values = ResourceValues.create( + { + name: "Office 2024", + templateId, + active: true, + notes: "Notas conservadas", + characteristics: { [fieldId]: "Microsoft" }, + }, + spec.data + ); + + assert.ok(values.isSuccess); + const created = Resource.create( + company, + spec.data, + values.data, + actor, + "2026-01-01T12:00:00.000Z" + ); + + assert.ok(created.isSuccess); + let resources = [created.data]; + let inUse = false; + + const context = (): InventoryContext => ({ + catalog: ResourceCatalog.create(), + resources, + references: [], + actorNames: new Map([[actor.toPrimitive(), "Técnico"]]), + }); + + const repository: IResourceRepository = { + get: async () => Result.ok(context()), + save: async (id, action) => { + const changed = action({ ...context(), resources: id.equals(company) ? resources : [] }); + + if (!changed.isSuccess) { + return Result.fail(changed.error); + } + resources = [changed.data]; + + return Result.ok({ resource: changed.data, actorNames: context().actorNames }); + }, + remove: async (id, identity) => { + if (!(id.equals(company) && resources.some((resource) => resource.id.equals(identity)))) { + return Result.fail(new ResourceCatalogError("RESOURCE_NOT_FOUND")); + } + if (inUse) { + return Result.fail(new ResourceCatalogError("RESOURCE_IN_USE")); + } + resources = []; + + return Result.ok(undefined); + }, + }; + + return { + service: new ResourceUseCases(repository), + created: created.data, + company: company.toPrimitive(), + actor: actor.toPrimitive(), + setInUse: () => { + inUse = true; + }, + }; +} + +test("recurso: activar/desactivar conserva datos e historial y repetir estado no añade revisiones", async () => { + const { service, company, actor, created } = fixture(); + + for (const [active, revisions] of [ + [false, 2], + [false, 2], + [true, 3], + ] as const) { + const result = await service.setActive(company, created.id.toPrimitive(), active, actor); + + assert.ok(result.isSuccess); + assert.equal(result.data.resource.values.toPrimitive().active, active); + assert.equal(result.data.resource.history.length, revisions); + assert.deepEqual( + result.data.resource.values.toPrimitive().characteristics, + created.values.toPrimitive().characteristics + ); + assert.equal( + result.data.resource.values.toPrimitive().notes, + created.values.toPrimitive().notes + ); + assert.equal(result.data.resource.history.at(-1)?.actorId.toPrimitive(), actor); + } +}); + +test("recurso: valida identidades y rechaza actuar sobre recursos ajenos", async () => { + const { service, company, actor, created } = fixture(); + const id = created.id.toPrimitive(); + const invalid = await service.setActive("invalid", id, false, actor); + + assert.ok(!invalid.isSuccess); + assert.equal(invalid.error.code, "INVALID_REQUEST"); + const invalidActor = await service.setActive(company, id, false, "invalid"); + + assert.ok(!invalidActor.isSuccess); + const invalidDelete = await service.remove(company, "invalid"); + + assert.ok(!invalidDelete.isSuccess); + const other = UniqueID.generateNewID().toPrimitive(); + const foreign = await service.setActive(other, id, false, actor); + + assert.ok(!foreign.isSuccess); + assert.equal(foreign.error.code, "RESOURCE_NOT_FOUND"); + const foreignDelete = await service.remove(other, id); + + assert.ok(!foreignDelete.isSuccess); + assert.equal(foreignDelete.error.code, "RESOURCE_NOT_FOUND"); +}); + +test("recurso: propaga el bloqueo por asociaciones y permite desactivar", async () => { + const { service, company, actor, created, setInUse } = fixture(); + + setInUse(); + const removed = await service.remove(company, created.id.toPrimitive()); + + assert.ok(!removed.isSuccess); + assert.equal(removed.error.code, "RESOURCE_IN_USE"); + assert.ok((await service.setActive(company, created.id.toPrimitive(), false, actor)).isSuccess); +}); + +test("recurso: elimina y la repetición devuelve ausencia sin efectos adicionales", async () => { + const { service, company, created } = fixture(); + + assert.ok((await service.remove(company, created.id.toPrimitive())).isSuccess); + const missing = await service.remove(company, created.id.toPrimitive()); + + assert.ok(!missing.isSuccess); + assert.equal(missing.error.code, "RESOURCE_NOT_FOUND"); +}); diff --git a/modules/resources/src/api/application/resource.repository.ts b/modules/resources/src/api/application/resource.repository.ts index 56c9a74..9890129 100644 --- a/modules/resources/src/api/application/resource.repository.ts +++ b/modules/resources/src/api/application/resource.repository.ts @@ -14,6 +14,7 @@ export type InventoryContext = Readonly<{ }>; export interface IResourceRepository { + remove(companyId: UniqueID, id: UniqueID): Promise>; get(companyId: UniqueID): Promise>; save( companyId: UniqueID, diff --git a/modules/resources/src/api/application/resource.use-cases.ts b/modules/resources/src/api/application/resource.use-cases.ts index 7b07828..31fab68 100644 --- a/modules/resources/src/api/application/resource.use-cases.ts +++ b/modules/resources/src/api/application/resource.use-cases.ts @@ -4,6 +4,7 @@ import { Result } from "@repo/rdx-utils"; import { Resource } from "../domain/resource.aggregate"; import { ResourceCatalogError } from "../domain/resource-definition"; import type { ResourceInput } from "../domain/resource-values"; +import { ResourceValues } from "../domain/value-objects/resource-values.vo"; import type { IResourceRepository } from "./resource.repository"; import { mapResourceInput } from "./resource-input.mapper"; @@ -11,6 +12,45 @@ import { mapResourceInput } from "./resource-input.mapper"; export class ResourceUseCases { constructor(private readonly repository: IResourceRepository) {} + remove(companyId: string, id: string) { + const company = UniqueID.create(companyId); + const identity = UniqueID.create(id); + + if (!(company.isSuccess && identity.isSuccess)) { + return Promise.resolve(Result.fail(new ResourceCatalogError("INVALID_REQUEST"))); + } + + return this.repository.remove(company.data, identity.data); + } + + setActive(companyId: string, id: string, active: boolean, actorId: string) { + const company = UniqueID.create(companyId); + const identity = UniqueID.create(id); + const actor = UniqueID.create(actorId); + + if (!(company.isSuccess && identity.isSuccess && actor.isSuccess)) { + return Promise.resolve(Result.fail(new ResourceCatalogError("INVALID_REQUEST"))); + } + + return this.repository.save(company.data, (context) => { + const previous = context.resources.find((resource) => resource.id.equals(identity.data)); + + if (!previous) { + return Result.fail(new ResourceCatalogError("RESOURCE_NOT_FOUND")); + } + const values = ResourceValues.create( + { ...previous.values.toPrimitive(), active }, + previous.specification + ); + + if (!values.isSuccess) { + return Result.fail(values.error); + } + + return previous.update(values.data, actor.data, new Date().toISOString()); + }); + } + get(companyId: string) { const company = UniqueID.create(companyId); diff --git a/modules/resources/src/api/infrastructure/http/resource.routes.ts b/modules/resources/src/api/infrastructure/http/resource.routes.ts index 8d2cb53..3a34d86 100644 --- a/modules/resources/src/api/infrastructure/http/resource.routes.ts +++ b/modules/resources/src/api/infrastructure/http/resource.routes.ts @@ -6,16 +6,43 @@ import { InventoryParamsSchema, ResourceParamsSchema, SaveResourceRequestSchema, + SetResourceActiveRequestSchema, } from "../../../common/inventory"; import type { ResourceUseCases } from "../../application/resource.use-cases"; import { buildResourceResponse } from "../../application/resource-response.builder"; +const LifecycleRequestSchema = z.discriminatedUnion("action", [ + z.strictObject({ action: z.literal("status"), body: SetResourceActiveRequestSchema }), + z.strictObject({ action: z.literal("delete"), body: z.strictObject({}) }), +]); + 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") }); } +function sendResource( + response: Response, + result: Awaited> +) { + if (!result.isSuccess) { + fail(response, result.error.code); + + return; + } + response.json({ resource: buildResourceResponse(result.data.resource, result.data.actorNames) }); +} + +function sendDeleted(response: Response, result: Awaited>) { + if (!result.isSuccess) { + fail(response, result.error.code); + + return; + } + response.json({ deleted: true }); +} + export function registerResourceRoutes( app: Application, useCases: ResourceUseCases, @@ -23,6 +50,41 @@ export function registerResourceRoutes( ) { const router = Router(); + for (const action of ["status", "delete"] as const) { + router.post(`/:companyId/:id/${action}`, (request, response, next) => { + const params = ResourceParamsSchema.safeParse(request.params); + const actor = z + .object({ userId: z.uuid() }) + .safeParse("session" in request ? request.session : undefined); + const body = LifecycleRequestSchema.safeParse({ action, body: request.body }); + + if ( + !( + params.success && + actor.success && + body.success && + z.strictObject({}).safeParse(request.query).success + ) + ) { + fail(response, "INVALID_REQUEST"); + + return; + } + if (body.data.action === "delete") { + useCases + .remove(params.data.companyId, params.data.id) + .then((result) => sendDeleted(response, result)) + .catch(next); + + return; + } + useCases + .setActive(params.data.companyId, params.data.id, body.data.body.active, actor.data.userId) + .then((result) => sendResource(response, result)) + .catch(next); + }); + } + router.get("/:companyId", (request, response, next) => { const params = InventoryParamsSchema.safeParse(request.params); diff --git a/modules/resources/src/api/infrastructure/persistence/remove-resource.ts b/modules/resources/src/api/infrastructure/persistence/remove-resource.ts new file mode 100644 index 0000000..7e1d01d --- /dev/null +++ b/modules/resources/src/api/infrastructure/persistence/remove-resource.ts @@ -0,0 +1,121 @@ +import { Result } from "@repo/rdx-utils"; +import type { Sequelize, Transaction } from "sequelize"; +import { QueryTypes } from "sequelize"; + +import type { Resource } from "../../domain/resource.aggregate"; +import { ResourceCatalogError } from "../../domain/resource-definition"; + +import { sqlIdentifier } from "./resource-relational-sql"; + +const OWNED_TABLES = [ + "resource_revision_characteristics", + "resource_characteristics", + "resource_specification_field_choices", + "resource_specification_fields", + "resource_specification_sections", + "resource_revisions", +]; + +const PROJECTION_COLUMNS = new Map([ + ["mailboxes", new Set(["mailbox_revisions.itemId"])], + [ + "access_items", + new Set([ + "access_resource_values.ownerId", + "access_revisions.itemId", + "access_references.itemId", + ]), + ], +]); + +/** Conserva cualquier asociación externa, incluso histórica; solo borra datos propios y proyecciones. */ +export async function removeResource( + database: Sequelize, + resource: Resource, + transaction: Transaction +) { + const id = resource.id.toPrimitive(); + const companyId = resource.companyId.toPrimitive(); + const integration = resource.values.toPrimitive().integration; + const roots = ["resources"]; + + if (integration) { + roots.push(integration.kind === "mailbox" ? "mailboxes" : "access_items"); + } + // El bloqueo de las raíces protege también frente a nuevas asociaciones concurrentes por FK. + for (const table of roots) { + await database.query(`SELECT id FROM ${sqlIdentifier(table)} WHERE id=:id FOR UPDATE`, { + replacements: { id }, + type: QueryTypes.SELECT, + transaction, + }); + } + const references = await database.query<{ tableName: string; columnName: string; root: string }>( + "SELECT TABLE_NAME AS tableName,COLUMN_NAME AS columnName,REFERENCED_TABLE_NAME AS root FROM information_schema.KEY_COLUMN_USAGE WHERE REFERENCED_TABLE_SCHEMA=DATABASE() AND REFERENCED_TABLE_NAME IN (:roots) AND REFERENCED_COLUMN_NAME='id'", + { replacements: { roots }, type: QueryTypes.SELECT, transaction } + ); + + for (const reference of references) { + const owned = + reference.root === "resources" + ? OWNED_TABLES.includes(reference.tableName) && reference.columnName === "resourceId" + : PROJECTION_COLUMNS.get(reference.root)?.has( + `${reference.tableName}.${reference.columnName}` + ); + + if (owned) { + continue; + } + const rows = await database.query<{ value: number }>( + `SELECT 1 AS value FROM ${sqlIdentifier(reference.tableName)} WHERE ${sqlIdentifier(reference.columnName)}=:id LIMIT 1`, + { replacements: { id }, type: QueryTypes.SELECT, transaction } + ); + + if (rows.length) { + return Result.fail(new ResourceCatalogError("RESOURCE_IN_USE")); + } + } + for (const table of OWNED_TABLES) { + await database.query(`DELETE FROM ${sqlIdentifier(table)} WHERE resourceId=:id`, { + replacements: { id }, + transaction, + }); + } + await database.query("DELETE FROM resources WHERE id=:id AND companyId=:companyId", { + replacements: { id, companyId }, + transaction, + }); + if (integration?.kind === "mailbox") { + await database.query("DELETE FROM mailbox_revisions WHERE itemId=:id", { + replacements: { id }, + transaction, + }); + await database.query("DELETE FROM mailboxes WHERE id=:id AND companyId=:companyId", { + replacements: { id, companyId }, + transaction, + }); + } else if (integration?.kind === "access") { + await database.query("DELETE FROM access_references WHERE itemId=:id", { + replacements: { id }, + transaction, + }); + await database.query( + "DELETE FROM access_resource_revision_values WHERE ownerId IN (SELECT id FROM access_revisions WHERE itemId=:id)", + { replacements: { id }, transaction } + ); + await database.query("DELETE FROM access_revisions WHERE itemId=:id", { + replacements: { id }, + transaction, + }); + await database.query("DELETE FROM access_resource_values WHERE ownerId=:id", { + replacements: { id }, + transaction, + }); + await database.query("DELETE FROM access_items WHERE id=:id", { + replacements: { id }, + transaction, + }); + } + + return Result.ok(undefined); +} diff --git a/modules/resources/src/api/infrastructure/persistence/sequelize-resource.repository.ts b/modules/resources/src/api/infrastructure/persistence/sequelize-resource.repository.ts index 06f18a7..24f6010 100644 --- a/modules/resources/src/api/infrastructure/persistence/sequelize-resource.repository.ts +++ b/modules/resources/src/api/infrastructure/persistence/sequelize-resource.repository.ts @@ -8,6 +8,7 @@ import { ResourceReferenceSchema } from "../../../common/inventory"; import type { IResourceRepository, InventoryContext } from "../../application/resource.repository"; import { ResourceCatalogError } from "../../domain/resource-definition"; +import { removeResource } from "./remove-resource"; import { ResourceAvailabilityModel, ResourceBindingModel, @@ -24,6 +25,23 @@ export class SequelizeResourceRepository implements IResourceRepository { constructor(private readonly database: Sequelize) {} + remove(companyId: UniqueID, id: UniqueID) { + return this.database.transaction(async (transaction) => { + const loaded = await this.load(companyId, transaction, true); + + if (!loaded.isSuccess) { + return Result.fail(loaded.error); + } + const resource = loaded.data.resources.find((item) => item.id.equals(id)); + + if (!resource) { + return Result.fail(new ResourceCatalogError("RESOURCE_NOT_FOUND")); + } + + return removeResource(this.database, resource, transaction); + }); + } + private async load( companyId: UniqueID, transaction: Transaction, diff --git a/modules/resources/src/common/inventory.ts b/modules/resources/src/common/inventory.ts index c0dc140..40b0a28 100644 --- a/modules/resources/src/common/inventory.ts +++ b/modules/resources/src/common/inventory.ts @@ -16,6 +16,10 @@ export const ResourceParamsSchema = InventoryParamsSchema.extend({ id: z.uuid() export const SaveResourceRequestSchema = z.strictObject({ values: ResourceInputSchema }); +export const SetResourceActiveRequestSchema = z.strictObject({ active: z.boolean() }); + +export const DeleteResourceResponseSchema = z.strictObject({ deleted: z.literal(true) }); + export const ResourceRevisionSchema = z.strictObject({ id: z.uuid(), values: ResourceInputSchema, diff --git a/modules/resources/src/common/locales/en.json b/modules/resources/src/common/locales/en.json index 748080e..d9df5de 100644 --- a/modules/resources/src/common/locales/en.json +++ b/modules/resources/src/common/locales/en.json @@ -65,7 +65,8 @@ "COMPANY_NOT_FOUND": "Company not found.", "FORBIDDEN": "Your session does not allow this operation. Sign in again.", "UNAUTHENTICATED": "Sign in again.", - "INTERNAL_ERROR": "The operation could not be completed." + "INTERNAL_ERROR": "The operation could not be completed.", + "RESOURCE_IN_USE": "The resource has current or historical associations. Deactivate it to preserve them or remove its associations before deleting it." }, "yes": "Yes", "no": "No", @@ -214,5 +215,14 @@ "templateDescription": "Select the template that defines the resource fields and configuration.", "fieldsDescription": "Complete the template details. Fields marked with * are required.", "nameDescription": "Review the suggested name and add notes if you need to provide more context." + }, + "resourceLifecycle": { + "activate": "Activate resource", + "deactivate": "Deactivate resource", + "delete": "Delete permanently", + "description": "Delete “{{name}}”? The resource and its history will be deleted. This action cannot be undone.", + "pending": "Deleting…", + "error": "The resource could not be deleted.", + "inUse": "The resource has current or historical associations. Deactivate it to preserve them or remove its associations before deleting it." } -} \ No newline at end of file +} diff --git a/modules/resources/src/common/locales/es.json b/modules/resources/src/common/locales/es.json index 26bb18f..fd437d9 100644 --- a/modules/resources/src/common/locales/es.json +++ b/modules/resources/src/common/locales/es.json @@ -65,7 +65,8 @@ "COMPANY_NOT_FOUND": "No se ha encontrado la compañía.", "FORBIDDEN": "La sesión no permite esta operación. Vuelve a iniciar sesión.", "UNAUTHENTICATED": "Vuelve a iniciar sesión.", - "INTERNAL_ERROR": "No se ha podido completar la operación." + "INTERNAL_ERROR": "No se ha podido completar la operación.", + "RESOURCE_IN_USE": "El recurso tiene asociaciones actuales o históricas. Desactívalo para conservarlas o retira sus asociaciones antes de eliminarlo." }, "yes": "Sí", "no": "No", @@ -214,5 +215,14 @@ "templateDescription": "Selecciona la plantilla que define los campos y la configuración del recurso.", "fieldsDescription": "Completa los datos de la plantilla. Los campos marcados con * son obligatorios.", "nameDescription": "Revisa el nombre propuesto y añade notas si necesitas aportar más contexto." + }, + "resourceLifecycle": { + "activate": "Activar recurso", + "deactivate": "Desactivar recurso", + "delete": "Eliminar definitivamente", + "description": "¿Quieres eliminar «{{name}}»? Se borrarán el recurso y su historial. Esta acción no se puede deshacer.", + "pending": "Eliminando…", + "error": "No se ha podido eliminar el recurso.", + "inUse": "El recurso tiene asociaciones actuales o históricas. Desactívalo para conservarlas o retira sus asociaciones antes de eliminarlo." } -} \ No newline at end of file +} diff --git a/modules/resources/src/web/resources/lifecycle/ui/delete-resource-dialog.tsx b/modules/resources/src/web/resources/lifecycle/ui/delete-resource-dialog.tsx new file mode 100644 index 0000000..79ad5e2 --- /dev/null +++ b/modules/resources/src/web/resources/lifecycle/ui/delete-resource-dialog.tsx @@ -0,0 +1,56 @@ +import { ConfirmDeleteDialog } from "@repo/rdx-ui/components/confirm-delete-dialog"; +import { useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import type { ResourceDTO } from "../../../../common/inventory"; +import { InventoryApiError, inventoryApi } from "../../shared/api"; +import { resourceRelatedQueryKeys } from "../../shared/api/resource-related-query-keys"; + +export function DeleteResourceDialog({ + resource, + onClose, + onDeleted, +}: { + resource: ResourceDTO; + onClose: () => void; + onDeleted: () => void; +}) { + const { t } = useTranslation("resources"); + const client = useQueryClient(); + const [errorCode, setErrorCode] = useState(); + + return ( + { + setErrorCode(undefined); + try { + await inventoryApi.remove(resource.companyId, resource.id); + onDeleted(); + await Promise.all( + resourceRelatedQueryKeys(resource.companyId).map((queryKey) => + client.invalidateQueries({ queryKey }) + ) + ); + } catch (error) { + setErrorCode(error instanceof InventoryApiError ? error.code : "INTERNAL_ERROR"); + throw error; + } + }} + onOpenChange={(open) => { + if (!open) { + onClose(); + } + }} + open + pendingLabel={t("resourceLifecycle.pending")} + title={t("resourceLifecycle.delete")} + /> + ); +} diff --git a/modules/resources/src/web/resources/list/controllers/use-resources-page-controller.ts b/modules/resources/src/web/resources/list/controllers/use-resources-page-controller.ts index 1c0f29b..fd1e072 100644 --- a/modules/resources/src/web/resources/list/controllers/use-resources-page-controller.ts +++ b/modules/resources/src/web/resources/list/controllers/use-resources-page-controller.ts @@ -5,10 +5,13 @@ import { useListQuery, } from "@metis/core/client"; import { useCompactDetail } from "@repo/rdx-ui/hooks/use-compact-detail"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; +import type { ResourceDTO } from "../../../../common/inventory"; import { catalogApi, catalogKeys } from "../../../catalog/shared/api"; import { inventoryApi, inventoryKeys } from "../../shared/api"; +import { resourceRelatedQueryKeys } from "../../shared/api/resource-related-query-keys"; import type { ResourcesPageProps } from "../types"; function initialEditor(create: boolean) { @@ -33,6 +36,19 @@ export function useResourcesPageController({ }); const [editor, setEditor] = useState(() => initialEditor(initialCreate)); + const [deleteId, setDeleteId] = useState(); + const client = useQueryClient(); + const statusMutation = useMutation({ + mutationFn: (resource: ResourceDTO) => + inventoryApi.setActive(companyId, resource.id, !resource.values.active), + onSuccess: async () => { + await Promise.all( + resourceRelatedQueryKeys(companyId).map((queryKey) => + client.invalidateQueries({ queryKey }) + ) + ); + }, + }); const { selectedId, selectEntity, @@ -111,6 +127,23 @@ export function useResourcesPageController({ }) ?? []; return { + deleteId, + setDeleteId, + statusMutation, + resourceDeleted: (id: string) => { + if (selectedId === id) { + if (routed) { + const search = searchParams.toString(); + + navigateTo(`/companies/${companyId}/resources${search ? `?${search}` : ""}`); + } else { + selectEntity(undefined); + } + } + if (viewId === id) { + setLocalViewId(undefined); + } + }, search, setSearch, editor, diff --git a/modules/resources/src/web/resources/list/ui/resources-page.tsx b/modules/resources/src/web/resources/list/ui/resources-page.tsx index ca44c97..23c7cf7 100644 --- a/modules/resources/src/web/resources/list/ui/resources-page.tsx +++ b/modules/resources/src/web/resources/list/ui/resources-page.tsx @@ -16,6 +16,7 @@ import { useTranslation } from "react-i18next"; import { ResourceTemplateIcon } from "../../../catalog-templates/shared/ui/resource-template-icon"; import { InventoryFeedback } from "../../../shared"; import { ResourceDetailDialog, ResourceSelectionPanel } from "../../detail"; +import { DeleteResourceDialog } from "../../lifecycle/ui/delete-resource-dialog"; import { ResourceEditorDialog } from "../../shared"; import { useResourcesPageController } from "../controllers"; import type { ResourcesPageProps } from "../types"; @@ -45,9 +46,14 @@ export function ResourcesPage(props: ResourcesPageProps) { selectResource, detailOpen, setDetailOpen, + deleteId, + setDeleteId, + statusMutation, + resourceDeleted, } = useResourcesPageController(props); const { t, i18n } = useTranslation("resources"); const tabValue = templateId ?? "all"; + const deleting = query.data?.resources.find((resource) => resource.id === deleteId); if (missingView) { return ; @@ -87,6 +93,7 @@ export function ResourcesPage(props: ResourcesPageProps) { {loading &&

{t("loading")}

} + {(query.isError || catalog.isError) && (