feat: implement record deletion functionality across various configurations

- Added remove methods to repositories for application, certificate, folder, mail, and printer configurations.
- Implemented use cases for removing configurations, including validation for company and identity.
- Created HTTP routes for deleting configurations with appropriate request handling and error responses.
- Introduced a DeleteRecordDialog component for user confirmation before deletion.
- Updated UI pages to include delete actions for configurations with modal confirmation.
- Added integration tests to verify record deletion functionality and ensure data integrity.
- Documented the record deletion process and its implications in the project documentation.
This commit is contained in:
David Arranz 2026-10-10 00:19:35 +02:00
parent 564b666fcc
commit 6c6eee8f93
48 changed files with 883 additions and 2 deletions

View File

@ -42,6 +42,7 @@ import { verifyInventory } from "./inventory.integration";
import { verifyMail } from "./mail.integration";
import { verifyPrinters } from "./printers.integration";
import { verifyProcesses } from "./processes.integration";
import { verifyRecordDeletion } from "./record-deletion.integration";
import { verifyRelationships } from "./relationships.integration";
import { verifyRemoteConnections } from "./remote.integration";
import { verifyResourceMigration } from "./resource-migration.integration";
@ -910,6 +911,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
migratedServer.close((error) => (error ? reject(error) : resolve()))
);
}
await verifyRecordDeletion((path, body) => operator.request(path, body), database);
const countAfterAccess = await database.query<{ total: number }>(
"SELECT COUNT(*) AS total FROM users",
{ type: QueryTypes.SELECT }

View File

@ -0,0 +1,139 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import type { Sequelize } from "sequelize";
import { QueryTypes } from "sequelize";
type Request = (path: string, body?: unknown) => Promise<Response>;
export async function verifyRecordDeletion(request: Request, database: Sequelize) {
const counts = async () => {
const result: number[] = [];
for (const table of [
"company_contacts",
"assets",
"resources",
"work_profiles",
"access_items",
"mailbox_uses",
]) {
const rows = await database.query<{ count: number }>(
`SELECT COUNT(*) AS count FROM ${table}`,
{ type: QueryTypes.SELECT }
);
result.push(Number(rows[0]?.count));
}
return result;
};
const folderSubjects = await database.query<{
profileId: string;
companyId: string;
needId: string;
actorId: string;
}>(
"SELECT p.id AS profileId,p.companyId,i.id AS needId,u.id AS actorId FROM work_profiles p JOIN access_workspaces w ON w.companyId=p.companyId JOIN access_items i ON i.workspaceId=w.id CROSS JOIN users u WHERE JSON_UNQUOTE(JSON_EXTRACT(i.`values`, '$.kind'))='need' LIMIT 1",
{ type: QueryTypes.SELECT }
);
const subject = folderSubjects[0];
assert.ok(subject, "Debe existir un perfil y una necesidad de recurso para la prueba de carpeta");
const mappingId = randomUUID();
const values = JSON.stringify({
profileId: subject.profileId,
needId: subject.needId,
path: "\\\\SERVER\\Test",
driveLetter: null,
state: "pending",
notes: null,
});
await database.query(
"INSERT INTO folder_mappings (id,companyId,profileId,needId,`values`,currentKey,driveKey) VALUES (:id,:companyId,:profileId,:needId,:values,:currentKey,NULL)",
{
replacements: {
...subject,
id: mappingId,
values,
currentKey: `${subject.profileId}:${subject.needId}`,
},
}
);
await database.query(
"INSERT INTO folder_mapping_revisions (id,mappingId,position,`values`,actorId,recordedAt) VALUES (:id,:mappingId,0,:values,:actorId,:recordedAt)",
{
replacements: {
id: randomUUID(),
mappingId,
values,
actorId: subject.actorId,
recordedAt: new Date().toISOString(),
},
}
);
const before = await counts();
const specs = [
[
"application_configurations",
"application_configuration_revisions",
"configurationId",
"application-configurations",
],
[
"certificate_configurations",
"certificate_configuration_revisions",
"configurationId",
"certificate-configurations",
],
[
"printer_configurations",
"printer_configuration_revisions",
"configurationId",
"printer-configurations",
],
[
"mail_configurations",
"mail_configuration_revisions",
"configurationId",
"mail-configurations",
],
["folder_mappings", "folder_mapping_revisions", "mappingId", "folder-mappings"],
["asset_support", "asset_support_revisions", "connectionId", "asset-support"],
["asset_relationships", null, null, "assets"],
] as const;
for (const [table, history, foreignKey, endpoint] of specs) {
const rows = await database.query<{ id: string; companyId: string; assetId?: string }>(
`SELECT * FROM ${table}`,
{ type: QueryTypes.SELECT }
);
assert.ok(rows.length > 0, `No existen datos para probar ${table}`);
// Todos los estados, incluidos los cerrados, admiten corregir un alta equivocada.
for (const row of rows) {
const suffix =
endpoint === "asset-support"
? `${row.assetId}/${row.id}`
: endpoint === "assets"
? `relationships/${row.id}`
: row.id;
const path = `/${endpoint}/${row.companyId}/${suffix}/delete`;
assert.equal(
(await request(`/${endpoint}/${randomUUID()}/${suffix}/delete`, {})).status,
404
);
assert.equal((await request(path.replace(row.companyId, "invalid"), {})).status, 400);
assert.equal((await request(path, { unexpected: true })).status, 400);
assert.equal((await request(path, {})).status, 200);
assert.equal((await request(path, {})).status, 404);
const remaining = await database.query(`SELECT id FROM ${table} WHERE id=:id`, {
replacements: { id: row.id },
type: QueryTypes.SELECT,
});
assert.equal(remaining.length, 0);
if (history && foreignKey) {
const revisions = await database.query(
`SELECT id FROM ${history} WHERE ${foreignKey}=:id`,
{ replacements: { id: row.id }, type: QueryTypes.SELECT }
);
assert.equal(revisions.length, 0);
}
}
}
assert.deepEqual(await counts(), before, "La eliminación debe conservar las entidades de origen");
}

View File

@ -319,3 +319,9 @@ En el menú de secciones de una compañía, los elementos utilizan el color norm
El cuerpo desplazable `FormBody` reserva 4 px interiores en ambos laterales y al inicio para el anillo exterior de foco (3 px). No debe eliminarse ese espacio: `overflow-y-auto` también recorta horizontalmente y produciría un foco asimétrico en campos pegados al borde. Se conserva el scroll y las acciones fijas.
Los listados comparten la preferencia entity-page-size en localStorage: el número de elementos por página elegido en un listado se restaura en todos los demás. Solo se aceptan los tamaños disponibles en el selector; si el almacenamiento no está disponible o contiene otro valor, se usa el tamaño inicial.
### Confirmación de eliminación de registros
`ConfirmDeleteDialog` de `@repo/rdx-ui` presenta una confirmación modal con descripción de consecuencias, Cancelar y Eliminar. El consumidor aporta los textos traducidos y una operación asíncrona. Mientras se ejecuta, evita envíos repetidos y el cierre; si falla, mantiene el diálogo abierto y muestra un error. Solo se cierra tras completar la operación.
Los módulos conectan este componente mediante `DeleteRecordDialog`, separado de los menús de fila para que la confirmación permanezca montada cuando se cierre el menú. La operación invalida la consulta de la entidad y las revisiones de procesos de la compañía.

View File

@ -0,0 +1,11 @@
# Eliminación de registros creados por error
Se permite eliminar configuraciones de aplicaciones, correo, carpetas, impresoras y certificados de un perfil, métodos de asistencia técnica de un activo y relaciones entre activos. Está disponible para cualquier estado del registro, incluidos los retirados y cerrados. No se elimina el catálogo de tipos de relación ni el catálogo de herramientas de asistencia.
La acción se ofrece en el menú de fila de las configuraciones y en las fichas de asistencia y relaciones. Siempre requiere confirmación modal e informa de que la eliminación es definitiva e incluye el historial propio. Una configuración representa lo documentado en Metis Desk: borrarla no desinstala programas, elimina certificados del dispositivo, revoca permisos ni retira herramientas reales.
Cada operación valida los UUID, el cuerpo vacío y la consulta vacía, exige autenticación y la protección CSRF general del servidor, y restringe la escritura a la compañía indicada. Para asistencia se comprueba también el activo. La persistencia bloquea la compañía y el registro, elimina primero las revisiones propias y luego el registro, dentro de una transacción. No modifica activos, recursos, cuentas, personas, perfiles ni permisos. Un identificador inexistente o de otra compañía devuelve 404 sin modificar datos.
Las tareas de procesos que ya copiaron una referencia mantienen su descripción e historial: la eliminación del registro de origen no elimina el proceso. Al refrescar su revisión actual, ese registro deja de aparecer.
Las entidades con condiciones de eliminación (perfiles, puestos, activos, recursos y catálogos utilizados) quedan fuera de esta ampliación. Las eliminaciones de asignaciones a departamentos y procesos de personas ya estaban disponibles.

View File

@ -37,6 +37,14 @@ export class AssetRelationshipUseCases {
);
});
}
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 AssetCatalogError("INVALID_REQUEST")));
}
return this.repository.removeRelationship(company.data, identity.data);
}
end(companyId: string, id: string, actorId: string) {
const company = UniqueID.create(companyId);
const identity = UniqueID.create(id);

View File

@ -20,6 +20,7 @@ export type InventoryContext = Readonly<{
actorNames: ReadonlyMap<string, string>;
}>;
export interface IAssetRepository {
removeRelationship(company: UniqueID, id: UniqueID): Promise<Result<void, AssetCatalogError>>;
saveWorkstation(
companyId: UniqueID,
action: (context: InventoryContext) => Result<Workstation, AssetCatalogError>

View File

@ -11,6 +11,7 @@ export type SupportContext = Readonly<{
actorNames: ReadonlyMap<string, string>;
}>;
export interface ISupportRepository {
remove(company: UniqueID, asset: UniqueID, id: UniqueID): Promise<Result<void, SupportError>>;
get(company: UniqueID, asset: UniqueID): Promise<Result<SupportContext, SupportError>>;
save(
company: UniqueID,

View File

@ -18,6 +18,15 @@ export class SupportUseCases {
}
return this.repository.get(company.data, asset.data);
}
remove(companyId: string, assetId: string, id: string) {
const company = UniqueID.create(companyId);
const asset = UniqueID.create(assetId);
const identity = UniqueID.create(id);
if (!(company.isSuccess && asset.isSuccess && identity.isSuccess)) {
return Promise.resolve(Result.fail(new SupportError("INVALID_REQUEST")));
}
return this.repository.remove(company.data, asset.data, identity.data);
}
tools() {
return this.repository.tools();
}

View File

@ -77,5 +77,28 @@ export function registerRelationshipRoutes(
})
.catch(next);
});
router.post("/:companyId/relationships/:id/delete", (request, response, next) => {
const params = AssetParamsSchema.safeParse(request.params);
if (
!(
params.success &&
z.strictObject({}).safeParse(request.body).success &&
z.strictObject({}).safeParse(request.query).success
)
) {
fail(response, "INVALID_REQUEST");
return;
}
useCases
.remove(params.data.companyId, params.data.id)
.then((result) => {
if (!result.isSuccess) {
fail(response, result.error.code);
return;
}
response.json({ deleted: true });
})
.catch(next);
});
app.use("/api/assets", authenticate, router);
}

View File

@ -106,5 +106,28 @@ export function registerSupportRoutes(
.catch(next);
});
}
router.post("/:companyId/:assetId/:id/delete", (request, response, next) => {
const params = Params.extend({ id: z.uuid() }).safeParse(request.params);
if (
!(
params.success &&
Empty.safeParse(request.body).success &&
Empty.safeParse(request.query).success
)
) {
fail(response, "INVALID_REQUEST");
return;
}
useCases
.remove(params.data.companyId, params.data.assetId, params.data.id)
.then((result) => {
if (!result.isSuccess) {
fail(response, result.error.code);
return;
}
response.json({ deleted: true });
})
.catch(next);
});
app.use("/api/asset-support", authenticate, router);
}

View File

@ -190,6 +190,25 @@ export class SequelizeAssetRepository implements IAssetRepository {
await WorkstationAssetModel.upsert(reference, { transaction });
}
}
removeRelationship(company: UniqueID, id: UniqueID): Promise<Result<void, AssetCatalogError>> {
return this.database.transaction(async (transaction) => {
await this.database.query("SELECT id FROM companies WHERE id=:companyId FOR UPDATE", {
replacements: { companyId: company.toPrimitive() },
transaction,
type: QueryTypes.SELECT,
});
const row = await RelationshipModel.findOne({
where: { companyId: company.toPrimitive(), id: id.toPrimitive() },
transaction,
lock: transaction.LOCK.UPDATE,
});
if (!row) {
return Result.fail(new AssetCatalogError("RELATIONSHIP_NOT_FOUND"));
}
await row.destroy({ transaction });
return Result.ok(undefined);
});
}
saveRelationship(
companyId: UniqueID,
action: Parameters<IAssetRepository["saveRelationship"]>[1]

View File

@ -66,6 +66,36 @@ export class SequelizeSupportRepository implements ISupportRepository {
actorNames: new Map(users.map((item) => [item.id, item.name])),
});
}
remove(company: UniqueID, asset: UniqueID, id: UniqueID): Promise<Result<void, SupportError>> {
return this.database.transaction(async (transaction) => {
const replacements = {
companyId: company.toPrimitive(),
assetId: asset.toPrimitive(),
id: id.toPrimitive(),
};
await this.database.query("SELECT id FROM companies WHERE id=:companyId FOR UPDATE", {
replacements,
transaction,
type: QueryTypes.SELECT,
});
const rows = await this.database.query<{ id: string }>(
"SELECT id FROM asset_support WHERE companyId=:companyId AND assetId=:assetId AND id=:id FOR UPDATE",
{ replacements, transaction, type: QueryTypes.SELECT }
);
if (!rows.length) {
return Result.fail(new SupportError("CONNECTION_NOT_FOUND"));
}
await this.database.query("DELETE FROM asset_support_revisions WHERE connectionId=:id", {
replacements,
transaction,
});
await this.database.query(
"DELETE FROM asset_support WHERE companyId=:companyId AND assetId=:assetId AND id=:id",
{ replacements, transaction }
);
return Result.ok(undefined);
});
}
async save(
company: UniqueID,
asset: UniqueID,

View File

@ -308,5 +308,11 @@
"identification": "Identification",
"assignment": "Location and assignment",
"notes": "Notes"
},
"deleteRecord": {
"title": "Delete record",
"description": "This record and its history will be deleted. Related assets, resources, accounts and people will be preserved. This action cannot be undone.",
"pending": "Deleting…",
"error": "The record could not be deleted. Please try again."
}
}

View File

@ -308,5 +308,11 @@
"identification": "Identificación",
"assignment": "Ubicación y asignación",
"notes": "Observaciones"
},
"deleteRecord": {
"title": "Eliminar registro",
"description": "Se eliminarán este registro y su historial. Los activos, recursos, cuentas y personas relacionados se conservarán. Esta acción no se puede deshacer.",
"pending": "Eliminando…",
"error": "No se ha podido eliminar el registro. Inténtalo de nuevo."
}
}

View File

@ -1,8 +1,10 @@
import { BrowserLink } from "@metis/core/client";
import { Button } from "@repo/shadcn-ui/components/button";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { RelationshipDTO } from "../../../../common";
import { DeleteRecordDialog } from "../../../shared/ui/delete-record-dialog";
import { EndRelationshipDialog } from "../../end";
import { RelationshipEditorDialog } from "../../shared";
import { useAssetRelationshipsController } from "../controllers";
@ -13,6 +15,7 @@ export function AssetRelationships(props: AssetRelationshipsProps) {
const { query, adding, setAdding, setEndingId, ending, active, ended, dateOf } =
useAssetRelationshipsController(props);
const { t } = useTranslation("assets");
const [deleteId, setDeleteId] = useState<string>();
const nameOf = (id: string) =>
query.data?.assets.find((asset) => asset.id === id)?.values.name ?? id;
const render = (link: RelationshipDTO) => {
@ -34,6 +37,9 @@ export function AssetRelationships(props: AssetRelationshipsProps) {
<p className="text-xs text-muted-foreground">
{t("relationCreated", { name: link.createdByName, date: dateOf(link.createdAt) })}
</p>
<Button onClick={() => setDeleteId(link.id)} size="sm" variant="outline">
{t("deleteRecord.title")}
</Button>
{link.endedAt ? (
<p className="text-xs text-muted-foreground">
{t("relationEnded", { name: link.endedByName, date: dateOf(link.endedAt) })}
@ -73,6 +79,13 @@ export function AssetRelationships(props: AssetRelationshipsProps) {
<ul className="mt-3 space-y-3">{ended.map(render)}</ul>
</details>
)}
<DeleteRecordDialog
companyId={companyId}
csrfToken={csrfToken}
onClose={() => setDeleteId(undefined)}
path={deleteId ? `/api/assets/${companyId}/relationships/${deleteId}/delete` : undefined}
queryKey={["inventory", companyId]}
/>
{adding && (
<RelationshipEditorDialog
assetId={assetId}

View File

@ -0,0 +1,13 @@
import { z } from "zod/v4";
export async function deleteRecord(path: string, token: string) {
const response = await fetch(path, {
method: "POST",
credentials: "same-origin",
headers: { "content-type": "application/json", "x-csrf-token": token },
body: "{}",
});
if (!response.ok) {
throw new Error("DELETE_FAILED");
}
z.strictObject({ deleted: z.literal(true) }).parse(await response.json());
}

View File

@ -0,0 +1,48 @@
import { ConfirmDeleteDialog } from "@repo/rdx-ui/components/confirm-delete-dialog";
import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { deleteRecord } from "../api/delete-record.api";
export function DeleteRecordDialog({
path,
csrfToken,
queryKey,
onClose,
companyId,
}: {
path: string | undefined;
csrfToken: string;
queryKey: readonly string[];
onClose: () => void;
companyId: string;
}) {
const client = useQueryClient();
const { t } = useTranslation("assets");
return (
<ConfirmDeleteDialog
cancelLabel={t("cancel")}
confirmLabel={t("deleteRecord.title")}
description={t("deleteRecord.description")}
errorLabel={t("deleteRecord.error")}
onConfirm={async () => {
if (!path) {
return;
}
await deleteRecord(path, csrfToken);
await Promise.all([
client.invalidateQueries({ queryKey }),
client.invalidateQueries({ queryKey: ["person-processes", companyId] }),
client.invalidateQueries({ queryKey: ["process-review", companyId] }),
]);
}}
onOpenChange={(open) => {
if (!open) {
onClose();
}
}}
open={Boolean(path)}
pendingLabel={t("deleteRecord.pending")}
title={t("deleteRecord.title")}
/>
);
}

View File

@ -1,6 +1,8 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { DeleteRecordDialog } from "../../../shared/ui/delete-record-dialog";
import { SupportToolsDialog } from "../../../support-tools";
import { SupportSummary } from "../../detail";
import { SupportHistoryDialog } from "../../history";
@ -26,6 +28,7 @@ export function AssetSupport(props: AssetSupportProps) {
tools,
} = useAssetSupportController(props);
const { t } = useTranslation("assets");
const [deleteId, setDeleteId] = useState<string>();
return (
<section className={compact ? "space-y-3" : "space-y-3 rounded-md border p-4"}>
<header className="flex flex-wrap items-center justify-between gap-3">
@ -82,6 +85,9 @@ export function AssetSupport(props: AssetSupportProps) {
<p className="text-sm text-amber-700">{t("support.review")}</p>
)}
<div className="flex flex-wrap gap-2">
<Button onClick={() => setDeleteId(item.id)} size="sm" variant="outline">
{t("deleteRecord.title")}
</Button>
<Button
onClick={() => {
setEdit(item);
@ -101,6 +107,15 @@ export function AssetSupport(props: AssetSupportProps) {
</ul>
</>
)}
<DeleteRecordDialog
companyId={companyId}
csrfToken={csrfToken}
onClose={() => setDeleteId(undefined)}
path={
deleteId ? `/api/asset-support/${companyId}/${assetId}/${deleteId}/delete` : undefined
}
queryKey={["asset-support", companyId, assetId]}
/>
<SupportConnectionEditorDialog
assetId={assetId}
companyId={companyId}

View File

@ -17,6 +17,7 @@ export type ApplicationContext = Readonly<{
actors: ReadonlyMap<string, string>;
}>;
export interface IApplicationConfigurationRepository {
remove(company: UniqueID, id: UniqueID): Promise<Result<void, ApplicationConfigurationError>>;
get(company: UniqueID): Promise<Result<ApplicationContext, ApplicationConfigurationError>>;
save(
company: UniqueID,

View File

@ -9,6 +9,14 @@ import type { IApplicationConfigurationRepository } from "./application-configur
import { mapApplicationInput } from "./application-configuration-input.mapper";
export class ApplicationConfigurationUseCases {
constructor(private readonly repository: IApplicationConfigurationRepository) {}
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 ApplicationConfigurationError("INVALID_REQUEST")));
}
return this.repository.remove(company.data, identity.data);
}
get(companyId: string) {
const company = UniqueID.create(companyId);
return company.isSuccess

View File

@ -19,6 +19,7 @@ export type CertificateContext = Readonly<{
actors: ReadonlyMap<string, string>;
}>;
export interface ICertificateConfigurationRepository {
remove(company: UniqueID, id: UniqueID): Promise<Result<void, CertificateConfigurationError>>;
get(company: UniqueID): Promise<Result<CertificateContext, CertificateConfigurationError>>;
save(
company: UniqueID,

View File

@ -9,6 +9,14 @@ import type { ICertificateConfigurationRepository } from "./certificate-configur
import { mapCertificateInput } from "./certificate-configuration-input.mapper";
export class CertificateConfigurationUseCases {
constructor(private readonly repository: ICertificateConfigurationRepository) {}
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 CertificateConfigurationError("INVALID_REQUEST")));
}
return this.repository.remove(company.data, identity.data);
}
get(companyId: string) {
const company = UniqueID.create(companyId);
return company.isSuccess

View File

@ -15,6 +15,7 @@ export type FolderContext = Readonly<{
today: string;
}>;
export interface IFolderMappingRepository {
remove(company: UniqueID, id: UniqueID): Promise<Result<void, FolderMappingError>>;
get(company: UniqueID): Promise<Result<FolderContext, FolderMappingError>>;
save(
company: UniqueID,

View File

@ -9,6 +9,14 @@ import type { IFolderMappingRepository } from "./folder-mapping.repository";
import { mapFolderInput } from "./folder-mapping-input.mapper";
export class FolderMappingUseCases {
constructor(private readonly repository: IFolderMappingRepository) {}
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 FolderMappingError("INVALID_REQUEST")));
}
return this.repository.remove(company.data, identity.data);
}
get(companyId: string) {
const company = UniqueID.create(companyId);
return company.isSuccess

View File

@ -20,6 +20,7 @@ export type MailContext = Readonly<{
today: string;
}>;
export interface IMailConfigurationRepository {
remove(company: UniqueID, id: UniqueID): Promise<Result<void, MailConfigurationError>>;
get(companyId: UniqueID): Promise<Result<MailContext, MailConfigurationError>>;
save(
companyId: UniqueID,

View File

@ -9,6 +9,14 @@ import type { IMailConfigurationRepository } from "./mail-configuration.reposito
import { mapMailInput } from "./mail-configuration-input.mapper";
export class MailConfigurationUseCases {
constructor(private readonly repository: IMailConfigurationRepository) {}
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 MailConfigurationError("INVALID_REQUEST")));
}
return this.repository.remove(company.data, identity.data);
}
get(companyId: string) {
const company = UniqueID.create(companyId);
return company.isSuccess

View File

@ -12,6 +12,7 @@ export type PrinterContext = Readonly<{
actors: ReadonlyMap<string, string>;
}>;
export interface IPrinterConfigurationRepository {
remove(company: UniqueID, id: UniqueID): Promise<Result<void, PrinterConfigurationError>>;
get(company: UniqueID): Promise<Result<PrinterContext, PrinterConfigurationError>>;
save(
company: UniqueID,

View File

@ -9,6 +9,14 @@ import type { IPrinterConfigurationRepository } from "./printer-configuration.re
import { mapPrinterInput } from "./printer-configuration-input.mapper";
export class PrinterConfigurationUseCases {
constructor(private readonly repository: IPrinterConfigurationRepository) {}
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 PrinterConfigurationError("INVALID_REQUEST")));
}
return this.repository.remove(company.data, identity.data);
}
get(companyId: string) {
const company = UniqueID.create(companyId);
return company.isSuccess

View File

@ -78,5 +78,28 @@ export function registerApplicationConfigurationRoutes(
.catch(next);
});
}
router.post("/:companyId/:id/delete", (request, response, next) => {
const params = ApplicationItemParamsSchema.safeParse(request.params);
if (
!(
params.success &&
z.strictObject({}).safeParse(request.body).success &&
z.strictObject({}).safeParse(request.query).success
)
) {
fail(response, "INVALID_REQUEST");
return;
}
useCases
.remove(params.data.companyId, params.data.id)
.then((result) => {
if (!result.isSuccess) {
fail(response, result.error.code);
return;
}
response.json({ deleted: true });
})
.catch(next);
});
app.use("/api/application-configurations", authenticate, router);
}

View File

@ -78,5 +78,28 @@ export function registerCertificateConfigurationRoutes(
.catch(next);
});
}
router.post("/:companyId/:id/delete", (request, response, next) => {
const params = CertificateItemParamsSchema.safeParse(request.params);
if (
!(
params.success &&
z.strictObject({}).safeParse(request.body).success &&
z.strictObject({}).safeParse(request.query).success
)
) {
fail(response, "INVALID_REQUEST");
return;
}
useCases
.remove(params.data.companyId, params.data.id)
.then((result) => {
if (!result.isSuccess) {
fail(response, result.error.code);
return;
}
response.json({ deleted: true });
})
.catch(next);
});
app.use("/api/certificate-configurations", authenticate, router);
}

View File

@ -73,5 +73,28 @@ export function registerFolderMappingRoutes(
.catch(next);
});
}
router.post("/:companyId/:id/delete", (request, response, next) => {
const params = MailItemParamsSchema.safeParse(request.params);
if (
!(
params.success &&
z.strictObject({}).safeParse(request.body).success &&
z.strictObject({}).safeParse(request.query).success
)
) {
fail(response, "INVALID_REQUEST");
return;
}
useCases
.remove(params.data.companyId, params.data.id)
.then((result) => {
if (!result.isSuccess) {
fail(response, result.error.code);
return;
}
response.json({ deleted: true });
})
.catch(next);
});
app.use("/api/folder-mappings", authenticate, router);
}

View File

@ -78,5 +78,28 @@ export function registerMailRoutes(
.catch(next);
});
}
router.post("/:companyId/:id/delete", (request, response, next) => {
const params = MailItemParamsSchema.safeParse(request.params);
if (
!(
params.success &&
z.strictObject({}).safeParse(request.body).success &&
z.strictObject({}).safeParse(request.query).success
)
) {
fail(response, "INVALID_REQUEST");
return;
}
useCases
.remove(params.data.companyId, params.data.id)
.then((result) => {
if (!result.isSuccess) {
fail(response, result.error.code);
return;
}
response.json({ deleted: true });
})
.catch(next);
});
app.use("/api/mail-configurations", authenticate, router);
}

View File

@ -78,5 +78,28 @@ export function registerPrinterConfigurationRoutes(
.catch(next);
});
}
router.post("/:companyId/:id/delete", (request, response, next) => {
const params = PrinterItemParamsSchema.safeParse(request.params);
if (
!(
params.success &&
z.strictObject({}).safeParse(request.body).success &&
z.strictObject({}).safeParse(request.query).success
)
) {
fail(response, "INVALID_REQUEST");
return;
}
useCases
.remove(params.data.companyId, params.data.id)
.then((result) => {
if (!result.isSuccess) {
fail(response, result.error.code);
return;
}
response.json({ deleted: true });
})
.catch(next);
});
app.use("/api/printer-configurations", authenticate, router);
}

View File

@ -104,6 +104,34 @@ export class SequelizeApplicationConfigurationRepository
get(company: UniqueID) {
return this.database.transaction((t) => this.load(company, t, false));
}
remove(company: UniqueID, id: UniqueID): Promise<Result<void, ApplicationConfigurationError>> {
return this.database.transaction(async (transaction) => {
const replacements = { companyId: company.toPrimitive(), id: id.toPrimitive() };
await this.database.query("SELECT id FROM companies WHERE id=:companyId FOR UPDATE", {
replacements,
transaction,
type: QueryTypes.SELECT,
});
const rows = await this.database.query<{ id: string }>(
"SELECT id FROM application_configurations WHERE companyId=:companyId AND id=:id FOR UPDATE",
{ replacements, transaction, type: QueryTypes.SELECT }
);
if (!rows.length) {
return Result.fail(
new ApplicationConfigurationError("APPLICATION_CONFIGURATION_NOT_FOUND")
);
}
await this.database.query(
"DELETE FROM application_configuration_revisions WHERE configurationId=:id",
{ replacements, transaction }
);
await this.database.query(
"DELETE FROM application_configurations WHERE companyId=:companyId AND id=:id",
{ replacements, transaction }
);
return Result.ok(undefined);
});
}
save(company: UniqueID, action: Parameters<IApplicationConfigurationRepository["save"]>[1]) {
return this.database.transaction(async (transaction) => {
const loaded = await this.load(company, transaction, true);

View File

@ -105,6 +105,34 @@ export class SequelizeCertificateConfigurationRepository
get(company: UniqueID) {
return this.database.transaction((t) => this.load(company, t, false));
}
remove(company: UniqueID, id: UniqueID): Promise<Result<void, CertificateConfigurationError>> {
return this.database.transaction(async (transaction) => {
const replacements = { companyId: company.toPrimitive(), id: id.toPrimitive() };
await this.database.query("SELECT id FROM companies WHERE id=:companyId FOR UPDATE", {
replacements,
transaction,
type: QueryTypes.SELECT,
});
const rows = await this.database.query<{ id: string }>(
"SELECT id FROM certificate_configurations WHERE companyId=:companyId AND id=:id FOR UPDATE",
{ replacements, transaction, type: QueryTypes.SELECT }
);
if (!rows.length) {
return Result.fail(
new CertificateConfigurationError("CERTIFICATE_CONFIGURATION_NOT_FOUND")
);
}
await this.database.query(
"DELETE FROM certificate_configuration_revisions WHERE configurationId=:id",
{ replacements, transaction }
);
await this.database.query(
"DELETE FROM certificate_configurations WHERE companyId=:companyId AND id=:id",
{ replacements, transaction }
);
return Result.ok(undefined);
});
}
save(company: UniqueID, action: Parameters<ICertificateConfigurationRepository["save"]>[1]) {
return this.database.transaction(async (transaction) => {
const loaded = await this.load(company, transaction, true);

View File

@ -116,6 +116,32 @@ export class SequelizeFolderMappingRepository implements IFolderMappingRepositor
get(company: UniqueID) {
return this.database.transaction((t) => this.load(company, t, false));
}
remove(company: UniqueID, id: UniqueID): Promise<Result<void, FolderMappingError>> {
return this.database.transaction(async (transaction) => {
const replacements = { companyId: company.toPrimitive(), id: id.toPrimitive() };
await this.database.query("SELECT id FROM companies WHERE id=:companyId FOR UPDATE", {
replacements,
transaction,
type: QueryTypes.SELECT,
});
const rows = await this.database.query<{ id: string }>(
"SELECT id FROM folder_mappings WHERE companyId=:companyId AND id=:id FOR UPDATE",
{ replacements, transaction, type: QueryTypes.SELECT }
);
if (!rows.length) {
return Result.fail(new FolderMappingError("FOLDER_MAPPING_NOT_FOUND"));
}
await this.database.query("DELETE FROM folder_mapping_revisions WHERE mappingId=:id", {
replacements,
transaction,
});
await this.database.query(
"DELETE FROM folder_mappings WHERE companyId=:companyId AND id=:id",
{ replacements, transaction }
);
return Result.ok(undefined);
});
}
save(company: UniqueID, action: Parameters<IFolderMappingRepository["save"]>[1]) {
return this.database.transaction(async (transaction) => {
const loaded = await this.load(company, transaction, true);

View File

@ -166,6 +166,32 @@ export class SequelizeMailConfigurationRepository implements IMailConfigurationR
get(companyId: UniqueID) {
return this.database.transaction((transaction) => this.load(companyId, transaction, false));
}
remove(company: UniqueID, id: UniqueID): Promise<Result<void, MailConfigurationError>> {
return this.database.transaction(async (transaction) => {
const replacements = { companyId: company.toPrimitive(), id: id.toPrimitive() };
await this.database.query("SELECT id FROM companies WHERE id=:companyId FOR UPDATE", {
replacements,
transaction,
type: QueryTypes.SELECT,
});
const rows = await this.database.query<{ id: string }>(
"SELECT id FROM mail_configurations WHERE companyId=:companyId AND id=:id FOR UPDATE",
{ replacements, transaction, type: QueryTypes.SELECT }
);
if (!rows.length) {
return Result.fail(new MailConfigurationError("MAIL_CONFIGURATION_NOT_FOUND"));
}
await this.database.query(
"DELETE FROM mail_configuration_revisions WHERE configurationId=:id",
{ replacements, transaction }
);
await this.database.query(
"DELETE FROM mail_configurations WHERE companyId=:companyId AND id=:id",
{ replacements, transaction }
);
return Result.ok(undefined);
});
}
save(companyId: UniqueID, action: Parameters<IMailConfigurationRepository["save"]>[1]) {
return this.database.transaction(async (transaction) => {
const context = await this.load(companyId, transaction, true);

View File

@ -96,6 +96,32 @@ export class SequelizePrinterConfigurationRepository implements IPrinterConfigur
get(company: UniqueID) {
return this.database.transaction((t) => this.load(company, t, false));
}
remove(company: UniqueID, id: UniqueID): Promise<Result<void, PrinterConfigurationError>> {
return this.database.transaction(async (transaction) => {
const replacements = { companyId: company.toPrimitive(), id: id.toPrimitive() };
await this.database.query("SELECT id FROM companies WHERE id=:companyId FOR UPDATE", {
replacements,
transaction,
type: QueryTypes.SELECT,
});
const rows = await this.database.query<{ id: string }>(
"SELECT id FROM printer_configurations WHERE companyId=:companyId AND id=:id FOR UPDATE",
{ replacements, transaction, type: QueryTypes.SELECT }
);
if (!rows.length) {
return Result.fail(new PrinterConfigurationError("PRINTER_CONFIGURATION_NOT_FOUND"));
}
await this.database.query(
"DELETE FROM printer_configuration_revisions WHERE configurationId=:id",
{ replacements, transaction }
);
await this.database.query(
"DELETE FROM printer_configurations WHERE companyId=:companyId AND id=:id",
{ replacements, transaction }
);
return Result.ok(undefined);
});
}
save(company: UniqueID, action: Parameters<IPrinterConfigurationRepository["save"]>[1]) {
return this.database.transaction(async (transaction) => {
const loaded = await this.load(company, transaction, true);

View File

@ -920,7 +920,13 @@
"deleteAssignmentConfirm": "Delete the assignment to {{name}}?",
"deleteAssignmentError": "The assignment could not be deleted.",
"endDateColumn": "End date",
"photoOptional": "Photo (optional)"
"photoOptional": "Photo (optional)",
"deleteRecord": {
"title": "Delete record",
"description": "This record and its history will be deleted. Related assets, resources, accounts and people will be preserved. This action cannot be undone.",
"pending": "Deleting…",
"error": "The record could not be deleted. Please try again."
}
},
"access": {
"helpTitle": "What is it and what is it for?",

View File

@ -920,7 +920,13 @@
"deleteAssignmentConfirm": "¿Eliminar la asignación al departamento {{name}}?",
"deleteAssignmentError": "No se puede eliminar la asignación.",
"endDateColumn": "Fecha de fin",
"photoOptional": "Foto (opcional)"
"photoOptional": "Foto (opcional)",
"deleteRecord": {
"title": "Eliminar registro",
"description": "Se eliminarán este registro y su historial. Los activos, recursos, cuentas y personas relacionados se conservarán. Esta acción no se puede deshacer.",
"pending": "Eliminando…",
"error": "No se ha podido eliminar el registro. Inténtalo de nuevo."
}
},
"access": {
"helpTitle": "¿Qué es y para qué sirve?",

View File

@ -6,8 +6,10 @@ import {
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { Button } from "@repo/shadcn-ui/components/button";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { DeleteRecordDialog } from "../../../shared/ui/delete-record-dialog";
import { ApplicationHistoryDialog } from "../../history";
import { ApplicationConfigurationEditorDialog, ApplicationFeedback } from "../../shared";
import { useApplicationConfigurationsPageController } from "../controllers";
@ -34,8 +36,18 @@ export function ApplicationConfigurationsPage(props: ApplicationConfigurationsPa
canCreate,
} = useApplicationConfigurationsPageController(props);
const { t, i18n } = useTranslation("companies");
const [deleteId, setDeleteId] = useState<string>();
return (
<div className="space-y-5">
<DeleteRecordDialog
companyId={company.id}
csrfToken={csrfToken}
onClose={() => setDeleteId(undefined)}
path={
deleteId ? `/api/application-configurations/${company.id}/${deleteId}/delete` : undefined
}
queryKey={["application-configurations", company.id]}
/>
<EntityListHeader
actions={
<>
@ -137,6 +149,9 @@ export function ApplicationConfigurationsPage(props: ApplicationConfigurationsPa
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
)}
</div>{" "}
<EntityAction className="text-destructive" onClick={() => setDeleteId(item.id)}>
{t("deleteRecord.title")}
</EntityAction>
</EntityRowActions>
),
},

View File

@ -6,8 +6,10 @@ import {
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { Button } from "@repo/shadcn-ui/components/button";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { DeleteRecordDialog } from "../../../shared/ui/delete-record-dialog";
import { CertificateHistoryDialog } from "../../history";
import { CertificateConfigurationEditorDialog, CertificateFeedback } from "../../shared";
import { useCertificateConfigurationsPageController } from "../controllers";
@ -34,8 +36,18 @@ export function CertificateConfigurationsPage(props: CertificateConfigurationsPa
canCreate,
} = useCertificateConfigurationsPageController(props);
const { t, i18n } = useTranslation("companies");
const [deleteId, setDeleteId] = useState<string>();
return (
<div className="space-y-5">
<DeleteRecordDialog
companyId={company.id}
csrfToken={csrfToken}
onClose={() => setDeleteId(undefined)}
path={
deleteId ? `/api/certificate-configurations/${company.id}/${deleteId}/delete` : undefined
}
queryKey={["certificate-configurations", company.id]}
/>
<EntityListHeader
actions={
<>
@ -144,6 +156,9 @@ export function CertificateConfigurationsPage(props: CertificateConfigurationsPa
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
)}
</div>{" "}
<EntityAction className="text-destructive" onClick={() => setDeleteId(item.id)}>
{t("deleteRecord.title")}
</EntityAction>
</EntityRowActions>
),
},

View File

@ -6,8 +6,10 @@ import {
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { Button } from "@repo/shadcn-ui/components/button";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { DeleteRecordDialog } from "../../../shared/ui/delete-record-dialog";
import { FolderMappingHistoryDialog } from "../../history";
import { FolderFeedback, FolderMappingEditorDialog } from "../../shared";
import { useFolderMappingsPageController } from "../controllers";
@ -32,8 +34,16 @@ export function FolderMappingsPage(props: FolderMappingsPageProps) {
ready,
} = useFolderMappingsPageController(props);
const { t, i18n } = useTranslation("companies");
const [deleteId, setDeleteId] = useState<string>();
return (
<div className="space-y-5">
<DeleteRecordDialog
companyId={company.id}
csrfToken={csrfToken}
onClose={() => setDeleteId(undefined)}
path={deleteId ? `/api/folder-mappings/${company.id}/${deleteId}/delete` : undefined}
queryKey={["folder-mappings", company.id]}
/>
<EntityListHeader
actions={
<>
@ -145,6 +155,9 @@ export function FolderMappingsPage(props: FolderMappingsPageProps) {
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
)}
</div>{" "}
<EntityAction className="text-destructive" onClick={() => setDeleteId(item.id)}>
{t("deleteRecord.title")}
</EntityAction>
</EntityRowActions>
),
},

View File

@ -6,8 +6,10 @@ import {
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { Button } from "@repo/shadcn-ui/components/button";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { DeleteRecordDialog } from "../../../shared/ui/delete-record-dialog";
import { MailConfigurationHistoryDialog } from "../../history";
import { MailConfigurationEditorDialog, MailFeedback } from "../../shared";
import { useMailConfigurationsPageController } from "../controllers";
@ -32,8 +34,16 @@ export function MailConfigurationsPage(props: MailConfigurationsPageProps) {
filtered,
} = useMailConfigurationsPageController(props);
const { t, i18n } = useTranslation("companies");
const [deleteId, setDeleteId] = useState<string>();
return (
<div className="space-y-5">
<DeleteRecordDialog
companyId={company.id}
csrfToken={csrfToken}
onClose={() => setDeleteId(undefined)}
path={deleteId ? `/api/mail-configurations/${company.id}/${deleteId}/delete` : undefined}
queryKey={["mail-configurations", company.id]}
/>
<EntityListHeader
actions={
<>
@ -136,6 +146,9 @@ export function MailConfigurationsPage(props: MailConfigurationsPageProps) {
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
)}
</div>{" "}
<EntityAction className="text-destructive" onClick={() => setDeleteId(item.id)}>
{t("deleteRecord.title")}
</EntityAction>
</EntityRowActions>
),
},

View File

@ -6,8 +6,10 @@ import {
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { Button } from "@repo/shadcn-ui/components/button";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { DeleteRecordDialog } from "../../../shared/ui/delete-record-dialog";
import { PrinterHistoryDialog } from "../../history";
import { PrinterConfigurationEditorDialog, PrinterFeedback } from "../../shared";
import { usePrinterConfigurationsPageController } from "../controllers";
@ -33,8 +35,16 @@ export function PrinterConfigurationsPage(props: PrinterConfigurationsPageProps)
canCreate,
} = usePrinterConfigurationsPageController(props);
const { t, i18n } = useTranslation("companies");
const [deleteId, setDeleteId] = useState<string>();
return (
<div className="space-y-5">
<DeleteRecordDialog
companyId={company.id}
csrfToken={csrfToken}
onClose={() => setDeleteId(undefined)}
path={deleteId ? `/api/printer-configurations/${company.id}/${deleteId}/delete` : undefined}
queryKey={["printer-configurations", company.id]}
/>
<EntityListHeader
actions={
<>
@ -131,6 +141,9 @@ export function PrinterConfigurationsPage(props: PrinterConfigurationsPageProps)
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
)}
</div>{" "}
<EntityAction className="text-destructive" onClick={() => setDeleteId(item.id)}>
{t("deleteRecord.title")}
</EntityAction>
</EntityRowActions>
),
},

View File

@ -0,0 +1,13 @@
import { z } from "zod/v4";
export async function deleteRecord(path: string, token: string) {
const response = await fetch(path, {
method: "POST",
credentials: "same-origin",
headers: { "content-type": "application/json", "x-csrf-token": token },
body: "{}",
});
if (!response.ok) {
throw new Error("DELETE_FAILED");
}
z.strictObject({ deleted: z.literal(true) }).parse(await response.json());
}

View File

@ -0,0 +1,48 @@
import { ConfirmDeleteDialog } from "@repo/rdx-ui/components/confirm-delete-dialog";
import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { deleteRecord } from "../api/delete-record.api";
export function DeleteRecordDialog({
path,
csrfToken,
queryKey,
onClose,
companyId,
}: {
path: string | undefined;
csrfToken: string;
queryKey: readonly string[];
onClose: () => void;
companyId: string;
}) {
const client = useQueryClient();
const { t } = useTranslation("companies");
return (
<ConfirmDeleteDialog
cancelLabel={t("cancel")}
confirmLabel={t("deleteRecord.title")}
description={t("deleteRecord.description")}
errorLabel={t("deleteRecord.error")}
onConfirm={async () => {
if (!path) {
return;
}
await deleteRecord(path, csrfToken);
await Promise.all([
client.invalidateQueries({ queryKey }),
client.invalidateQueries({ queryKey: ["person-processes", companyId] }),
client.invalidateQueries({ queryKey: ["process-review", companyId] }),
]);
}}
onOpenChange={(open) => {
if (!open) {
onClose();
}
}}
open={Boolean(path)}
pendingLabel={t("deleteRecord.pending")}
title={t("deleteRecord.title")}
/>
);
}

View File

@ -0,0 +1,72 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { useState } from "react";
import { FormActions, FormBody, FormDialog } from "./form-dialog";
export function ConfirmDeleteDialog({
open,
onOpenChange,
title,
description,
cancelLabel,
confirmLabel,
pendingLabel,
errorLabel,
onConfirm,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
cancelLabel: string;
confirmLabel: string;
pendingLabel: string;
errorLabel: string;
onConfirm: () => Promise<unknown>;
}) {
const [pending, setPending] = useState(false);
const [failed, setFailed] = useState(false);
return (
<FormDialog
cancelLabel={cancelLabel}
formActions
onOpenChange={(value) => {
if (!pending) {
setFailed(false);
onOpenChange(value);
}
}}
open={open}
title={title}
>
<FormBody>
<p>{description}</p>
{failed && (
<p className="text-destructive" role="alert">
{errorLabel}
</p>
)}
</FormBody>
<FormActions>
<Button
disabled={pending}
onClick={async () => {
setPending(true);
setFailed(false);
try {
await onConfirm();
onOpenChange(false);
} catch {
setFailed(true);
} finally {
setPending(false);
}
}}
variant="destructive"
>
{pending ? pendingLabel : confirmLabel}
</Button>
</FormActions>
</FormDialog>
);
}