From 34c0da30d8f393a1acc68686df80baf714a7ac6c Mon Sep 17 00:00:00 2001
From: Alberto Martinez <349769123@users.noreply.cursor.com>
Date: Wed, 16 Sep 2026 21:17:39 +0000
Subject: [PATCH] fix(padron): vista previa de documentos, eliminar y
reemplazar por tipo (#26)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Problema
En el expediente del padrón, al ver un documento el navegador lo trataba como descarga (cabeceras `application/octet-stream` + `attachment` y blob sin MIME). No había forma de eliminar archivos cargados y al subir otro del mismo tipo algunos entornos fallaban o no refrescaban bien la lista vigente.
## Cambios
- **API:** los GET de documentos (trabajador, proyecto, empresa) envían el `Content-Type` real del archivo y `Content-Disposition: inline` por defecto (`?disposition=attachment` para forzar descarga).
- **UI:** `DocumentPreviewDialog` pide `disposition=inline` y reconstruye el blob con MIME correcto para que PDF/imagen se vean en el diálogo.
- **Eliminar:** RPC `core.fn_worker_document_delete`, `DELETE /v1/workers/:id/documents/:docId`, permiso `documents.delete` (incluido en `manage_documents`), botón de papelera en vigentes e histórico.
- **Reemplazar:** `fn_worker_document_store` marca como no vigentes **todas** las filas del mismo tipo antes del INSERT; se limpia el `FileUpload` tras subir; filtro `is_current` tolera booleano o `1`.
## Migraciones
- Core: `040-fn-worker-document-delete.sql`, `041-fn-worker-document-store-replace.sql`
- IAM: `006-documents-delete-permission.sql`
## Verificación
Tras aplicar Liquibase, en un trabajador del padrón: abrir ojo en PDF/JPG (debe verse en modal), eliminar un doc de prueba, subir dos veces el mismo tipo (debe quedar una vigente y la anterior en histórico).
---
api/document_serve.ts | 24 +++
api/document_serve_test.ts | 13 ++
api/excel.ts | 15 ++
api/main.ts | 56 +++++--
api/storage.ts | 16 ++
db/core/changelog-master.xml | 2 +
.../040-fn-worker-document-delete.sql | 63 ++++++++
.../041-fn-worker-document-store-replace.sql | 153 ++++++++++++++++++
db/iam/changelog-master.xml | 1 +
.../006-documents-delete-permission.sql | 23 +++
.../components/DocumentPreviewDialog.vue | 23 ++-
.../components/padron/WorkerDocuments.vue | 65 +++++++-
12 files changed, 438 insertions(+), 16 deletions(-)
create mode 100644 api/document_serve.ts
create mode 100644 api/document_serve_test.ts
create mode 100644 db/core/changesets/040-fn-worker-document-delete.sql
create mode 100644 db/core/changesets/041-fn-worker-document-store-replace.sql
create mode 100644 db/iam/changesets/006-documents-delete-permission.sql
diff --git a/api/document_serve.ts b/api/document_serve.ts
new file mode 100644
index 0000000..1f81a5d
--- /dev/null
+++ b/api/document_serve.ts
@@ -0,0 +1,24 @@
+/** Cabeceras HTTP para bytes de documento cifrado ya descifrados. */
+export function documentServeHeaders(
+ doc: { original_name: string; mime?: string },
+ opts: { inline?: boolean } = {},
+): Record {
+ const inline = opts.inline !== false;
+ const mime = (doc.mime || "").trim() || "application/octet-stream";
+ const name = doc.original_name || "documento";
+ const disposition = inline ? "inline" : "attachment";
+ return {
+ "Content-Type": mime,
+ "X-Content-Type-Options": "nosniff",
+ "Content-Disposition": `${disposition}; filename="${encodeURIComponent(name)}"`,
+ "Cache-Control": "private, max-age=60",
+ };
+}
+
+export function wantsInlineDocumentDisposition(url: URL | string): boolean {
+ const u = typeof url === "string" ? new URL(url, "http://local") : url;
+ const d = (u.searchParams.get("disposition") || u.searchParams.get("inline") || "").toLowerCase();
+ if (d === "attachment" || d === "download" || d === "0" || d === "false") return false;
+ if (d === "inline" || d === "1" || d === "true") return true;
+ return true;
+}
diff --git a/api/document_serve_test.ts b/api/document_serve_test.ts
new file mode 100644
index 0000000..33ddc88
--- /dev/null
+++ b/api/document_serve_test.ts
@@ -0,0 +1,13 @@
+import { documentServeHeaders, wantsInlineDocumentDisposition } from "./document_serve.ts";
+
+Deno.test("documentServeHeaders inline uses mime", () => {
+ const h = documentServeHeaders({ original_name: "a.pdf", mime: "application/pdf" }, { inline: true });
+ if (h["Content-Type"] !== "application/pdf") throw new Error(h["Content-Type"]);
+ if (!h["Content-Disposition"].startsWith("inline;")) throw new Error(h["Content-Disposition"]);
+});
+
+Deno.test("wantsInlineDocumentDisposition", () => {
+ if (!wantsInlineDocumentDisposition("http://x/doc")) throw new Error("default inline");
+ if (wantsInlineDocumentDisposition("http://x/doc?disposition=attachment")) throw new Error("attachment");
+ if (!wantsInlineDocumentDisposition("http://x/doc?disposition=inline")) throw new Error("inline");
+});
diff --git a/api/excel.ts b/api/excel.ts
index cf37498..39af560 100644
--- a/api/excel.ts
+++ b/api/excel.ts
@@ -247,6 +247,21 @@ export async function storeDocument(
await refreshPipeline(db, workerId);
}
+export async function deleteWorkerDocument(
+ db: Db,
+ workerId: number,
+ docId: number,
+): Promise<{ storage_name: string }> {
+ const env = await callCoreFn<{ storage_name?: string }>(db, "core.fn_worker_document_delete", {
+ worker_id: workerId,
+ doc_id: docId,
+ });
+ if (!env.ok) throw new RpcCallError(env);
+ const storage = String(env.data?.storage_name ?? "");
+ await refreshPipeline(db, workerId);
+ return { storage_name: storage };
+}
+
export async function storeProjectDocument(
db: Db,
projectId: number,
diff --git a/api/main.ts b/api/main.ts
index fe77bab..571e102 100644
--- a/api/main.ts
+++ b/api/main.ts
@@ -17,6 +17,7 @@ import {
} from "./db.ts";
import { callCoreFn } from "./rpc.ts";
import { normalizeDocumentGetPayload } from "./document_rpc.ts";
+import { documentServeHeaders, wantsInlineDocumentDisposition } from "./document_serve.ts";
import {
respondRpc,
respondApiError,
@@ -50,7 +51,7 @@ import { createTenant, getTenantDetail, issueTenantAdminAccess, listTenants, upd
import { smtpConfigured, testSmtp } from "./mail.ts";
import { saveSmtpSettings, smtpPublicView } from "./smtp.ts";
import { decryptBytes } from "./docs_crypto.ts";
-import { importExcel, storeDocument, storeProjectDocument, storeCompanyDocument, buildImportTemplate } from "./excel.ts";
+import { importExcel, storeDocument, storeProjectDocument, storeCompanyDocument, buildImportTemplate, deleteWorkerDocument } from "./excel.ts";
import {
normalizeWorker,
validateCurp,
@@ -81,7 +82,7 @@ import {
importBudgetExcel,
listBudget,
} from "./budget.ts";
-import { badgeJobPdfKey, companyDocKey, getObject, pingStorage, projectDocKey, workerDocKey } from "./storage.ts";
+import { badgeJobPdfKey, companyDocKey, deleteObject, getObject, pingStorage, projectDocKey, workerDocKey } from "./storage.ts";
import { cacheCore, cacheKeyCore } from "./cache.ts";
import { pingRedis } from "./redis.ts";
@@ -128,6 +129,17 @@ async function rpcDocumentForDownload(
return { doc, envelope };
}
+function applyDocumentResponseHeaders(
+ c: { header: (name: string, value: string) => void; req: { url: string } },
+ doc: StorageDoc,
+ inline?: boolean,
+) {
+ const useInline = inline ?? wantsInlineDocumentDisposition(c.req.url);
+ for (const [name, value] of Object.entries(documentServeHeaders(doc, { inline: useInline }))) {
+ c.header(name, value);
+ }
+}
+
async function readExcelUpload(
c: { req: { formData: () => Promise } },
): Promise<{ file: File; bytes: Uint8Array } | { error: string }> {
@@ -473,9 +485,7 @@ app.get("/v1/companies/:id/documents/:docId", ...requireCoreAuth, requirePermiss
if (!doc) return respondRpc(c, envelope);
const enc = await getObject(companyDocKey(companyId, doc.storage_name));
const plain = await decryptBytes(doc.iv, enc);
- c.header("Content-Type", "application/octet-stream");
- c.header("X-Content-Type-Options", "nosniff");
- c.header("Content-Disposition", `attachment; filename="${encodeURIComponent(doc.original_name)}"`);
+ applyDocumentResponseHeaders(c, doc);
return c.body(plain.buffer as ArrayBuffer);
});
@@ -717,9 +727,7 @@ app.get("/v1/projects/:id/documents/:docId", ...requireCoreAuth, requirePermissi
if (!await denyUnlessProjectDocView(c, db, typeCode)) return;
const enc = await getObject(projectDocKey(projectId, doc.storage_name));
const plain = await decryptBytes(doc.iv, enc);
- c.header("Content-Type", "application/octet-stream");
- c.header("X-Content-Type-Options", "nosniff");
- c.header("Content-Disposition", `attachment; filename="${encodeURIComponent(doc.original_name)}"`);
+ applyDocumentResponseHeaders(c, doc);
return c.body(plain.buffer as ArrayBuffer);
});
@@ -1271,12 +1279,38 @@ app.get("/v1/workers/:id/documents/:docId", ...requireCoreAuth, requirePermissio
if (!doc) return respondRpc(c, envelope);
const enc = await getObject(workerDocKey(workerId, doc.storage_name));
const plain = await decryptBytes(doc.iv, enc);
- c.header("Content-Type", "application/octet-stream");
- c.header("X-Content-Type-Options", "nosniff");
- c.header("Content-Disposition", `attachment; filename="${encodeURIComponent(doc.original_name)}"`);
+ applyDocumentResponseHeaders(c, doc);
return c.body(plain.buffer as ArrayBuffer);
});
+app.delete("/v1/workers/:id/documents/:docId", ...requireCoreAuth, requirePermission("documents.delete"), async (c) => {
+ const workerId = Number(c.req.param("id"));
+ const docId = Number(c.req.param("docId"));
+ const db = c.get("db");
+ const tid = tenantScope(c.get("user"));
+ const route = routeLabel(c);
+ const workerEnv = await callCoreFn(db, "core.fn_worker_get", { id: workerId, tenant_id: tid }, { route });
+ if (!workerEnv.ok) return respondRpc(c, workerEnv);
+ try {
+ const { storage_name: storageName } = await deleteWorkerDocument(db, workerId, docId);
+ if (storageName) {
+ try {
+ await deleteObject(workerDocKey(workerId, storageName));
+ } catch {
+ /* metadatos ya eliminados; objeto huérfano es aceptable */
+ }
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "No se pudo eliminar el documento";
+ return respondApiError(c, "VALIDATION", message, { route, worker_id: workerId, doc_id: docId });
+ }
+ return c.json({
+ ok: true,
+ checklist: await checklistFor(db, workerId, tid),
+ ...await imssFlagsFor(db, workerId, tid),
+ });
+});
+
app.get("/v1/workers/:id/photo", ...requireCoreAuth, requirePermission("workers.view"), async (c) => {
const id = Number(c.req.param("id"));
const db = c.get("db");
diff --git a/api/storage.ts b/api/storage.ts
index 4ed3c3a..6701f41 100644
--- a/api/storage.ts
+++ b/api/storage.ts
@@ -158,6 +158,22 @@ export async function getObject(key: string): Promise {
return await readFile(path);
}
+export async function deleteObject(key: string): Promise {
+ if (s3Configured()) {
+ await getClient().send(
+ new DeleteObjectCommand({ Bucket: config.s3Bucket, Key: key }),
+ );
+ return;
+ }
+ const path = join(DATA_DIR, "local-objects", key);
+ try {
+ const { unlink } = await import("node:fs/promises");
+ await unlink(path);
+ } catch (e) {
+ if ((e as NodeJS.ErrnoException)?.code !== "ENOENT") throw e;
+ }
+}
+
// --- Convenciones de key por dominio (equivalentes a los antiguos
// workerDir/projectDir/companyDir + Deno.readFile/writeFile) ---
export function workerDocKey(workerId: number, storageName: string): string {
diff --git a/db/core/changelog-master.xml b/db/core/changelog-master.xml
index 5a115c5..216d163 100644
--- a/db/core/changelog-master.xml
+++ b/db/core/changelog-master.xml
@@ -49,5 +49,7 @@
+
+
diff --git a/db/core/changesets/040-fn-worker-document-delete.sql b/db/core/changesets/040-fn-worker-document-delete.sql
new file mode 100644
index 0000000..adbfbfe
--- /dev/null
+++ b/db/core/changesets/040-fn-worker-document-delete.sql
@@ -0,0 +1,63 @@
+--liquibase formatted sql
+-- PANELS · core · eliminar documento de expediente (trabajador)
+
+--changeset panel:core-040a-fn-worker-document-delete splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_worker_document_delete(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_worker_id bigint := NULLIF(payload->>'worker_id', '')::bigint;
+ v_doc_id bigint := NULLIF(payload->>'doc_id', '')::bigint;
+ v_doc documents%ROWTYPE;
+ v_next_id bigint;
+BEGIN
+ IF v_worker_id IS NULL OR v_doc_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_worker_document_delete: worker_id y doc_id son obligatorios',
+ jsonb_build_object('fn', 'fn_worker_document_delete', 'worker_id', v_worker_id, 'doc_id', v_doc_id)
+ );
+ END IF;
+ SELECT * INTO v_doc FROM documents WHERE id = v_doc_id AND worker_id = v_worker_id;
+ IF NOT FOUND THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_worker_document_delete: documento id=%s no encontrado para trabajador %s', v_doc_id, v_worker_id),
+ jsonb_build_object('fn', 'fn_worker_document_delete', 'doc_id', v_doc_id, 'worker_id', v_worker_id)
+ );
+ END IF;
+
+ DELETE FROM documents WHERE id = v_doc_id AND worker_id = v_worker_id;
+
+ IF v_doc.is_current THEN
+ SELECT id INTO v_next_id
+ FROM documents
+ WHERE worker_id = v_worker_id
+ AND type_code = v_doc.type_code
+ ORDER BY uploaded_at DESC, id DESC
+ LIMIT 1;
+ IF v_next_id IS NOT NULL THEN
+ UPDATE documents SET is_current = true WHERE id = v_next_id;
+ END IF;
+ END IF;
+
+ RETURN core.rpc_ok(
+ jsonb_build_object(
+ 'deleted_id', v_doc_id,
+ 'worker_id', v_worker_id,
+ 'type_code', v_doc.type_code,
+ 'storage_name', v_doc.storage_name
+ ),
+ format('Documento id=%s eliminado del expediente del trabajador %s', v_doc_id, v_worker_id),
+ jsonb_build_object('fn', 'fn_worker_document_delete', 'doc_id', v_doc_id, 'worker_id', v_worker_id)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_worker_document_delete', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-040b-fn-worker-document-delete-grant endDelimiter:; splitStatements:true
+GRANT EXECUTE ON FUNCTION core.fn_worker_document_delete(jsonb) TO panels_core_app;
diff --git a/db/core/changesets/041-fn-worker-document-store-replace.sql b/db/core/changesets/041-fn-worker-document-store-replace.sql
new file mode 100644
index 0000000..0f98976
--- /dev/null
+++ b/db/core/changesets/041-fn-worker-document-store-replace.sql
@@ -0,0 +1,153 @@
+--liquibase formatted sql
+-- PANELS · core · reemplazo de documento por tipo (siempre archiva versiones previas)
+
+--changeset panel:core-041a-fn-worker-document-store-replace splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_worker_document_store(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_worker_id bigint := NULLIF(payload->>'worker_id', '')::bigint;
+ v_type_code text := btrim(COALESCE(payload->>'type_code', payload->>'type', ''));
+ v_original_name text := btrim(COALESCE(payload->>'original_name', payload->>'filename', ''));
+ v_mime text := btrim(COALESCE(payload->>'mime', ''));
+ v_size_bytes bigint := NULLIF(payload->>'size_bytes', '')::bigint;
+ v_sha256 text := btrim(COALESCE(payload->>'sha256', ''));
+ v_iv text := btrim(COALESCE(payload->>'iv', ''));
+ v_storage_name text := btrim(COALESCE(payload->>'storage_name', ''));
+ v_uploaded_by_id integer := NULLIF(payload->>'uploaded_by_id', '')::integer;
+ v_uploaded_by_name text := COALESCE(NULLIF(btrim(payload->>'uploaded_by_name'), ''), '');
+ v_issued_at date := NULLIF(btrim(payload->>'issued_at'), '')::date;
+ v_expires_at date := NULLIF(btrim(payload->>'expires_at'), '')::date;
+ v_imss_company_id integer := NULLIF(payload->>'imss_company_id', '')::integer;
+ v_imss_alta_at date := NULLIF(btrim(payload->>'imss_alta_at'), '')::date;
+ v_imss_baja_at date := NULLIF(btrim(payload->>'imss_baja_at'), '')::date;
+ v_today date := current_date;
+ v_movement_date date;
+ v_doc_id bigint;
+ v_company record;
+BEGIN
+ IF v_worker_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_worker_document_store: worker_id es obligatorio',
+ jsonb_build_object('fn', 'fn_worker_document_store', 'field', 'worker_id')
+ );
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM workers WHERE id = v_worker_id) THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_worker_document_store: trabajador id=%s no encontrado', v_worker_id),
+ jsonb_build_object('fn', 'fn_worker_document_store', 'worker_id', v_worker_id)
+ );
+ END IF;
+ IF v_type_code = '' THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_worker_document_store: type_code es obligatorio',
+ jsonb_build_object('fn', 'fn_worker_document_store', 'field', 'type_code')
+ );
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM document_types WHERE code = v_type_code) THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ format('fn_worker_document_store: tipo de documento no válido (%s)', v_type_code),
+ jsonb_build_object('fn', 'fn_worker_document_store', 'type_code', v_type_code)
+ );
+ END IF;
+ IF v_original_name = '' OR v_mime = '' OR v_sha256 = '' OR v_iv = '' OR v_storage_name = '' THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_worker_document_store: original_name, mime, sha256, iv y storage_name son obligatorios tras la carga a almacenamiento',
+ jsonb_build_object('fn', 'fn_worker_document_store')
+ );
+ END IF;
+ IF v_size_bytes IS NULL OR v_size_bytes < 0 THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_worker_document_store: size_bytes debe ser un entero no negativo',
+ jsonb_build_object('fn', 'fn_worker_document_store', 'field', 'size_bytes')
+ );
+ END IF;
+
+ v_imss_alta_at := COALESCE(v_imss_alta_at, CASE WHEN v_type_code = 'alta_imss' THEN v_today END);
+ v_imss_baja_at := COALESCE(v_imss_baja_at, CASE WHEN v_type_code = 'baja_imss' THEN v_today END);
+ v_movement_date := CASE WHEN v_type_code = 'baja_imss' THEN v_imss_baja_at ELSE v_imss_alta_at END;
+
+ UPDATE documents
+ SET is_current = false
+ WHERE worker_id = v_worker_id
+ AND type_code = v_type_code;
+
+ INSERT INTO documents (
+ worker_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name,
+ is_current, parse_status, issued_at, expires_at, imss_company_id, imss_alta_at,
+ uploaded_by_id, uploaded_by_name
+ ) VALUES (
+ v_worker_id, v_type_code, v_original_name, v_mime, v_size_bytes, v_sha256, v_iv, v_storage_name,
+ true, 'manual',
+ COALESCE(v_issued_at, v_movement_date),
+ v_expires_at,
+ v_imss_company_id,
+ v_movement_date,
+ v_uploaded_by_id,
+ v_uploaded_by_name
+ )
+ RETURNING id INTO v_doc_id;
+
+ IF v_type_code = 'alta_imss' THEN
+ v_imss_company_id := COALESCE(
+ v_imss_company_id,
+ (SELECT company_id FROM workers WHERE id = v_worker_id)
+ );
+ SELECT id, code INTO v_company FROM companies WHERE id = v_imss_company_id;
+ IF NOT FOUND THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_worker_document_store: empresa patrón no válida para alta IMSS',
+ jsonb_build_object('fn', 'fn_worker_document_store', 'worker_id', v_worker_id)
+ );
+ END IF;
+ UPDATE workers SET
+ imss_status = 'alta',
+ imss_company_id = v_company.id,
+ imss_alta_at = v_imss_alta_at,
+ imss_baja_at = NULL,
+ company_id = v_company.id,
+ hire_type = v_company.code,
+ updated_at = now()
+ WHERE id = v_worker_id;
+ END IF;
+
+ IF v_type_code = 'baja_imss' THEN
+ UPDATE workers SET
+ imss_status = 'baja_imss',
+ imss_baja_at = v_imss_baja_at,
+ imss_company_id = NULL,
+ company_id = NULL,
+ hire_type = '',
+ updated_at = now()
+ WHERE id = v_worker_id;
+ END IF;
+
+ IF (SELECT status FROM workers WHERE id = v_worker_id) = 'baja' THEN
+ UPDATE workers SET pipeline_status = 'baja' WHERE id = v_worker_id;
+ END IF;
+
+ RETURN core.rpc_created(
+ jsonb_build_object(
+ 'id', v_doc_id,
+ 'worker_id', v_worker_id,
+ 'type_code', v_type_code,
+ 'is_current', true,
+ 'storage_name', v_storage_name
+ ),
+ format('Metadatos del documento %s guardados para trabajador %s (versión actual)', v_type_code, v_worker_id),
+ jsonb_build_object('fn', 'fn_worker_document_store', 'id', v_doc_id, 'worker_id', v_worker_id)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_worker_document_store', SQLSTATE, SQLERRM);
+END;
+$$;
diff --git a/db/iam/changelog-master.xml b/db/iam/changelog-master.xml
index befbd1a..c677a13 100644
--- a/db/iam/changelog-master.xml
+++ b/db/iam/changelog-master.xml
@@ -11,5 +11,6 @@
+
diff --git a/db/iam/changesets/006-documents-delete-permission.sql b/db/iam/changesets/006-documents-delete-permission.sql
new file mode 100644
index 0000000..cd89372
--- /dev/null
+++ b/db/iam/changesets/006-documents-delete-permission.sql
@@ -0,0 +1,23 @@
+--liquibase formatted sql
+-- PANELS · iam · permiso eliminar documentos de expediente
+
+--changeset panel:iam-006a-documents-delete endDelimiter:; splitStatements:true
+INSERT INTO iam.permissions (code, label, module, verb, perm_group) VALUES
+ ('documents.delete', 'Eliminar documentos de expediente', 'documents', 'delete', NULL)
+ON CONFLICT (code) DO UPDATE SET
+ label = EXCLUDED.label,
+ module = EXCLUDED.module,
+ verb = EXCLUDED.verb,
+ perm_group = EXCLUDED.perm_group;
+
+INSERT INTO iam._legacy_perm_map (legacy_code, v2_code) VALUES
+ ('manage_documents', 'documents.delete')
+ON CONFLICT (legacy_code, v2_code) DO NOTHING;
+
+INSERT INTO iam.role_permissions (role_id, permission_code)
+SELECT sr.id, 'documents.delete'
+ FROM iam.roles sr
+ WHERE sr.code = 'tenant_admin'
+ AND sr.is_system
+ AND sr.tenant_id IS NULL
+ON CONFLICT DO NOTHING;
diff --git a/web-panel/components/DocumentPreviewDialog.vue b/web-panel/components/DocumentPreviewDialog.vue
index a36d09a..e868a63 100644
--- a/web-panel/components/DocumentPreviewDialog.vue
+++ b/web-panel/components/DocumentPreviewDialog.vue
@@ -60,6 +60,25 @@ const kind = computed<"image" | "pdf" | "other">(() => {
return "other";
});
+function previewFetchPath(path: string) {
+ const sep = path.includes("?") ? "&" : "?";
+ return `${path}${sep}disposition=inline`;
+}
+
+function resolvedPreviewMime(raw: Blob): string {
+ const fromProp = (props.mime || "").trim();
+ if (fromProp) return fromProp;
+ const fromBlob = (raw.type || "").trim();
+ if (fromBlob && fromBlob !== "application/octet-stream") return fromBlob;
+ const name = props.name.toLowerCase();
+ if (name.endsWith(".pdf")) return "application/pdf";
+ if (/\.(jpe?g)$/.test(name)) return "image/jpeg";
+ if (name.endsWith(".png")) return "image/png";
+ if (name.endsWith(".webp")) return "image/webp";
+ if (name.endsWith(".gif")) return "image/gif";
+ return fromBlob || "application/octet-stream";
+}
+
async function loadPreview() {
revoke();
error.value = "";
@@ -67,7 +86,9 @@ async function loadPreview() {
if (!props.path) return;
loading.value = true;
try {
- const blob = await api(props.path);
+ const raw = await api(previewFetchPath(props.path));
+ const mime = resolvedPreviewMime(raw);
+ const blob = mime === raw.type ? raw : new Blob([raw], { type: mime });
blobRef.value = blob;
url.value = URL.createObjectURL(blob);
} catch (err: unknown) {
diff --git a/web-panel/components/padron/WorkerDocuments.vue b/web-panel/components/padron/WorkerDocuments.vue
index 0de7098..68cb879 100644
--- a/web-panel/components/padron/WorkerDocuments.vue
+++ b/web-panel/components/padron/WorkerDocuments.vue
@@ -61,6 +61,7 @@
/>
-
+