import { db } from "../db"; import { sql, eq } from "drizzle-orm"; import { fileUploads } from "@shared/schema"; // ── Отслеживание файлов (file_uploads) ─────────────────────────────────────── // canAccessFile (index.ts) и get_task_file (mcp.ts) работают fail-closed по этой // таблице. Файлы, загруженные до появления трекинга или со старыми АБСОЛЮТНЫМИ // URL (https://iistwin.ru/api/files/...), в таблице отсутствовали — startup // backfill понимал только относительные ссылки. Этот модуль — догрузка по // требованию: ищем файл в значениях file-полей и вложениях сообщений по ссылке // (любого вида) и записываем в file_uploads. interface FileReference { organizationId: number; uploadedBy: number | null; taskId: number | null; fieldId: number | null; name: string | null; size: number | null; } async function findFileReference(fileKey: string): Promise { // Значения file-полей задач (одиночные и массивные значения) const fieldRows = await db.execute(sql` SELECT t.organization_id AS org, t.created_by AS uploader, tfv.task_id AS task, tfv.field_id AS field, elem->>'name' AS name, (elem->>'size')::integer AS size FROM task_field_values tfv JOIN tasks t ON tfv.task_id = t.id JOIN form_fields ff ON tfv.field_id = ff.id CROSS JOIN LATERAL jsonb_array_elements( CASE WHEN jsonb_typeof(tfv.value) = 'array' THEN tfv.value ELSE jsonb_build_array(tfv.value) END ) elem WHERE ff.type = 'file' AND position(${fileKey} in coalesce(elem->>'url', '')) > 0 LIMIT 1 `); const fr = (fieldRows as unknown as { rows?: FileReferenceRow[] }).rows?.[0]; if (fr) { return { organizationId: fr.org, uploadedBy: fr.uploader ?? null, taskId: fr.task ?? null, fieldId: fr.field ?? null, name: fr.name ?? null, size: fr.size ?? null }; } // Вложения сообщений чата задач const msgRows = await db.execute(sql` SELECT f.organization_id AS org, COALESCE(tm.author_id, t.created_by) AS uploader, tm.task_id AS task, att->>'name' AS name, (att->>'size')::integer AS size FROM task_messages tm JOIN tasks t ON tm.task_id = t.id JOIN forms f ON t.form_id = f.id CROSS JOIN LATERAL jsonb_array_elements(tm.attachments) att WHERE tm.attachments IS NOT NULL AND jsonb_typeof(tm.attachments) = 'array' AND position(${fileKey} in coalesce(att->>'url', '')) > 0 LIMIT 1 `); const mr = (msgRows as unknown as { rows?: FileReferenceRow[] }).rows?.[0]; if (mr) { return { organizationId: mr.org, uploadedBy: mr.uploader ?? null, taskId: mr.task ?? null, fieldId: null, name: mr.name ?? null, size: mr.size ?? null }; } return null; } interface FileReferenceRow { org: number; uploader: number | null; task: number | null; field?: number | null; name: string | null; size: number | null; } /** * Догрузка по требованию: если файл не отслеживается, ищем его в значениях полей * и вложениях сообщений и записываем в file_uploads. * Возвращает true, если файл после вызова отслеживается. */ export async function trackFileOnDemand(fileKey: string): Promise { const existing = await db .select() .from(fileUploads) .where(eq(fileUploads.fileKey, fileKey)) .limit(1); try { // Уже отслеживается, но без привязки к задаче (старые backfill-строки) — // пытаемся дозаполнить taskId/fieldId из ссылок if (existing.length > 0) { const row = existing[0]; if (row.taskId == null) { const ref = await findFileReference(fileKey); if (ref?.taskId != null) { await db.update(fileUploads) .set({ taskId: ref.taskId, fieldId: row.fieldId ?? ref.fieldId }) .where(eq(fileUploads.id, row.id)); } } return true; } const ref = await findFileReference(fileKey); // uploadedBy NOT NULL в схеме — без владельца записать не сможем if (!ref || ref.uploadedBy == null) return false; await db.insert(fileUploads).values({ organizationId: ref.organizationId, uploadedBy: ref.uploadedBy, fileKey, originalName: ref.name, sizeBytes: ref.size, taskId: ref.taskId, fieldId: ref.fieldId, }).onConflictDoNothing(); return true; } catch { // Ошибка догрузки — fail-closed (доступ не открываем) return false; } }