Files
iistwin/server/utils/file-tracking.ts
Ильяс Султанов 0b4324c23d fix(files): принадлежность файла задаче — дозаполнение task_id/field_id + MCP-проверка с сериализацией значений
Живой тест показал: 77 файлов отслеживаются, но get_task_file отвечал «не
относится к задаче» — старые backfill-строки без task_id/field_id (ON CONFLICT
DO NOTHING их не обновлял), а проверка по file-полям искала ключ только в
строковых значениях (file-поля хранятся объектом/массивом).
- MCP get_task_file: поиск ключа в JSON.stringify(value) для нестроковых значений;
- trackFileOnDemand: у существующих строк без task_id дозаполняет taskId/fieldId;
- startup-backfill: UPDATE task_id/field_id из task_field_values и task_messages
  для строк с task_id IS NULL.
2026-09-29 10:50:34 +03:00

120 lines
4.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<FileReference | null> {
// Значения 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<boolean> {
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;
}
}