Живой тест показал: 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.
120 lines
4.9 KiB
TypeScript
120 lines
4.9 KiB
TypeScript
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;
|
||
}
|
||
}
|