fix(padron): vista previa inline, eliminar y reemplazar documentos

- Sirve documentos con Content-Type real e inline por defecto (query disposition)
- DocumentPreviewDialog reconstruye blob con MIME correcto para PDF/imagen
- RPC y DELETE para eliminar documentos del expediente; botón en UI
- Permiso IAM documents.delete; store archiva todas las versiones previas del tipo
- Limpia FileUpload tras subir; is_current robusto en listas vigente/histórico

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
This commit is contained in:
Cursor Agent 2026-09-16 21:11:11 +00:00
parent cb04a9a04c
commit 17d9393b9e
No known key found for this signature in database
11 changed files with 283 additions and 16 deletions

24
api/document_serve.ts Normal file
View file

@ -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<string, string> {
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;
}

View file

@ -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");
});

View file

@ -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,

View file

@ -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<FormData> } },
): 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");

View file

@ -158,6 +158,22 @@ export async function getObject(key: string): Promise<Uint8Array> {
return await readFile(path);
}
export async function deleteObject(key: string): Promise<void> {
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 {

View file

@ -49,5 +49,6 @@
<include file="changesets/037-fn-work-program-get-curve-vrow.sql" relativeToChangelogFile="true"/>
<include file="changesets/038-fn-work-program-vs-cost.sql" relativeToChangelogFile="true"/>
<include file="changesets/039-fn-next-project-code-global.sql" relativeToChangelogFile="true"/>
<include file="changesets/040-fn-worker-document-delete.sql" relativeToChangelogFile="true"/>
</databaseChangeLog>

View file

@ -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;

View file

@ -11,5 +11,6 @@
<include file="changesets/003-expenses-warehouse-permissions.sql" relativeToChangelogFile="true"/>
<include file="changesets/004-rpc-users-permissions.sql" relativeToChangelogFile="true"/>
<include file="changesets/005-iam-v2-roles-crud.sql" relativeToChangelogFile="true"/>
<include file="changesets/006-documents-delete-permission.sql" relativeToChangelogFile="true"/>
</databaseChangeLog>

View file

@ -0,0 +1,22 @@
--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 r.id, 'documents.delete'
FROM iam.roles r
WHERE r.is_system = true
AND r.code IN ('tenant_admin')
ON CONFLICT DO NOTHING;

View file

@ -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<Blob>(props.path);
const raw = await api<Blob>(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) {

View file

@ -61,6 +61,7 @@
/>
</FormField>
<FileUpload
ref="fileUploadRef"
mode="basic"
:auto="true"
choose-label="Seleccionar archivo"
@ -95,7 +96,19 @@
</Column>
<Column header="">
<template #body="{ data }">
<Button icon="pi pi-eye" text rounded size="small" aria-label="Ver documento" @click="openDocument(data)" />
<div class="flex gap-1">
<Button icon="pi pi-eye" text rounded size="small" aria-label="Ver documento" @click="openDocument(data)" />
<Button
v-if="!readonly && canDeleteDoc"
icon="pi pi-trash"
text
rounded
size="small"
severity="danger"
aria-label="Eliminar documento"
@click="confirmDelete(data)"
/>
</div>
</template>
</Column>
</DataTable>
@ -111,7 +124,19 @@
</Column>
<Column header="">
<template #body="{ data }">
<Button icon="pi pi-eye" text rounded size="small" aria-label="Ver documento" @click="openDocument(data)" />
<div class="flex gap-1">
<Button icon="pi pi-eye" text rounded size="small" aria-label="Ver documento" @click="openDocument(data)" />
<Button
v-if="!readonly && canDeleteDoc"
icon="pi pi-trash"
text
rounded
size="small"
severity="danger"
aria-label="Eliminar documento"
@click="confirmDelete(data)"
/>
</div>
</template>
</Column>
</DataTable>
@ -158,7 +183,11 @@ const props = defineProps<{
}>();
const emit = defineEmits<{ changed: []; registerAlta: [] }>();
const { api } = useApi();
const { can } = usePermissions();
const confirm = useConfirm();
const toast = useToast();
const fileUploadRef = ref<{ clear?: () => void } | null>(null);
const canDeleteDoc = computed(() => can("documents.delete") || can("documents.create"));
const documentType = ref(
props.initialType && props.initialType !== "alta_imss" && props.initialType !== "baja_imss"
@ -194,11 +223,14 @@ const requiredDone = computed(() => requiredItems.value.filter((item) => item.pr
const requiredProgress = computed(() =>
requiredItems.value.length ? Math.round((requiredDone.value / requiredItems.value.length) * 100) : 0,
);
function isDocCurrent(doc: DocumentRow) {
return doc.is_current === true || doc.is_current === 1;
}
const currentDocuments = computed(() =>
props.documents.filter((doc) => doc.is_current && doc.type_code !== "alta_imss" && doc.type_code !== "baja_imss"),
props.documents.filter((doc) => isDocCurrent(doc) && doc.type_code !== "alta_imss" && doc.type_code !== "baja_imss"),
);
const historyDocuments = computed(() =>
props.documents.filter((doc) => !doc.is_current && doc.type_code !== "alta_imss" && doc.type_code !== "baja_imss"),
props.documents.filter((doc) => !isDocCurrent(doc) && doc.type_code !== "alta_imss" && doc.type_code !== "baja_imss"),
);
const freshnessRequired = computed(() => !!props.freshnessRequired);
const imssReady = computed(() => !!props.imssReady);
@ -263,6 +295,7 @@ async function uploadDocument(event: { files: File[] }) {
body.append("type", documentType.value);
body.append("file", file);
await api(`/v1/workers/${props.workerId}/documents`, { method: "POST", body });
fileUploadRef.value?.clear?.();
toast.add({ severity: "success", summary: "Documento cifrado y guardado", life: 2200 });
docsTab.value = "current";
emit("changed");
@ -282,6 +315,30 @@ function openDocument(row: DocumentRow) {
previewMime.value = row.mime || null;
previewOpen.value = true;
}
function confirmDelete(row: DocumentRow) {
confirm.require({
message: `¿Eliminar ${typeLabel(row.type_code)} (${row.original_name})? Esta acción no se puede deshacer.`,
header: "Eliminar documento",
icon: "pi pi-exclamation-triangle",
acceptLabel: "Eliminar",
rejectLabel: "Cancelar",
acceptClass: "p-button-danger",
accept: () => deleteDocument(row),
});
}
async function deleteDocument(row: DocumentRow) {
try {
await api(`/v1/workers/${props.workerId}/documents/${row.id}`, { method: "DELETE" });
toast.add({ severity: "success", summary: "Documento eliminado", life: 2200 });
emit("changed");
} catch (error: unknown) {
toast.add({
severity: "error",
summary: error instanceof Error ? error.message : "No se pudo eliminar",
life: 3500,
});
}
}
watch(() => props.initialType, (value) => {
if (value && value !== "alta_imss" && value !== "baja_imss") documentType.value = value;