mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 12:43:18 +00:00
fix(padron): vista previa de documentos, eliminar y reemplazar por tipo (#26)
<!-- CURSOR_AGENT_PR_BODY_BEGIN --> ## 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). <!-- CURSOR_AGENT_PR_BODY_END --> <div><a href="https://cursor.com/agents/bc-d8a5d34a-ead9-4912-8296-322cff799783?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a> <a href="https://cursor.com/background-agent?bcId=bc-d8a5d34a-ead9-4912-8296-322cff799783&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a> </div>
This commit is contained in:
parent
cb04a9a04c
commit
34c0da30d8
12 changed files with 438 additions and 16 deletions
24
api/document_serve.ts
Normal file
24
api/document_serve.ts
Normal 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;
|
||||
}
|
||||
13
api/document_serve_test.ts
Normal file
13
api/document_serve_test.ts
Normal 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");
|
||||
});
|
||||
15
api/excel.ts
15
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,
|
||||
|
|
|
|||
56
api/main.ts
56
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<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");
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -49,5 +49,7 @@
|
|||
<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"/>
|
||||
<include file="changesets/041-fn-worker-document-store-replace.sql" relativeToChangelogFile="true"/>
|
||||
|
||||
</databaseChangeLog>
|
||||
|
|
|
|||
63
db/core/changesets/040-fn-worker-document-delete.sql
Normal file
63
db/core/changesets/040-fn-worker-document-delete.sql
Normal 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;
|
||||
153
db/core/changesets/041-fn-worker-document-store-replace.sql
Normal file
153
db/core/changesets/041-fn-worker-document-store-replace.sql
Normal file
|
|
@ -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;
|
||||
$$;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
23
db/iam/changesets/006-documents-delete-permission.sql
Normal file
23
db/iam/changesets/006-documents-delete-permission.sql
Normal file
|
|
@ -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;
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 }">
|
||||
<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 }">
|
||||
<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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue