From 0b4324c23dc6611633812214ae80bca12e51b543 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=D1=81=20=D0=A1=D1=83=D0=BB=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Tue, 29 Sep 2026 10:50:34 +0300 Subject: [PATCH] =?UTF-8?q?fix(files):=20=D0=BF=D1=80=D0=B8=D0=BD=D0=B0?= =?UTF-8?q?=D0=B4=D0=BB=D0=B5=D0=B6=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D1=84?= =?UTF-8?q?=D0=B0=D0=B9=D0=BB=D0=B0=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B5?= =?UTF-8?q?=20=E2=80=94=20=D0=B4=D0=BE=D0=B7=D0=B0=D0=BF=D0=BE=D0=BB=D0=BD?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20task=5Fid/field=5Fid=20+=20MCP-?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0=20=D1=81=20?= =?UTF-8?q?=D1=81=D0=B5=D1=80=D0=B8=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B5=D0=B9=20=D0=B7=D0=BD=D0=B0=D1=87=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Живой тест показал: 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. --- server/index.ts | 46 +++++++++++++++++++++++++++++++++++ server/mcp.ts | 7 +++++- server/utils/file-tracking.ts | 18 ++++++++++++-- 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/server/index.ts b/server/index.ts index bf96884..30407e2 100644 --- a/server/index.ts +++ b/server/index.ts @@ -997,6 +997,52 @@ async function runStartupDataPatches() { ON CONFLICT (file_key) DO NOTHING `).catch(() => {}); + // Дозаполнение task_id/field_id у старых строк, вставленных без привязки + // (ранние версии трекинга). ON CONFLICT DO NOTHING их не обновляет. + await db.execute(sql` + UPDATE file_uploads fu + SET task_id = src.task_id, field_id = src.field_id + FROM ( + SELECT DISTINCT + CASE + WHEN elem->>'url' LIKE '%/api/files/%' THEN regexp_replace(elem->>'url', '^.*/api/files/', '') + WHEN elem->>'url' LIKE '%/uploads/%' THEN regexp_replace(elem->>'url', '^.*/uploads/', '') + END AS fkey, + tfv.task_id, + tfv.field_id + FROM task_field_values tfv + 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 (elem->>'url' LIKE '%/api/files/%' OR elem->>'url' LIKE '%/uploads/%') + ) src + WHERE fu.file_key = src.fkey + AND src.fkey IS NOT NULL + AND fu.task_id IS NULL + `).catch(() => {}); + await db.execute(sql` + UPDATE file_uploads fu + SET task_id = src.task_id + FROM ( + SELECT DISTINCT + CASE + WHEN att->>'url' LIKE '%/api/files/%' THEN regexp_replace(att->>'url', '^.*/api/files/', '') + WHEN att->>'url' LIKE '%/uploads/%' THEN regexp_replace(att->>'url', '^.*/uploads/', '') + END AS fkey, + tm.task_id + FROM task_messages tm + CROSS JOIN LATERAL jsonb_array_elements(tm.attachments) att + WHERE tm.attachments IS NOT NULL + AND jsonb_typeof(tm.attachments) = 'array' + AND (att->>'url' LIKE '%/api/files/%' OR att->>'url' LIKE '%/uploads/%') + ) src + WHERE fu.file_key = src.fkey + AND src.fkey IS NOT NULL + AND fu.task_id IS NULL + `).catch(() => {}); + // RLS policies are created by migration 0011_rls_tasks_notnull.sql. // Startup only activates ENABLE + FORCE ROW LEVEL SECURITY when ENABLE_RLS=true, // then verifies all tables are correctly protected before allowing traffic. diff --git a/server/mcp.ts b/server/mcp.ts index bb5d18c..8bad017 100644 --- a/server/mcp.ts +++ b/server/mcp.ts @@ -4288,7 +4288,12 @@ To block task creation from task.before_create, set: ctx.result = { allow: false } if (!belongsToTask) { const fieldValues = await storage.getTaskFieldValues(taskId, organizationId); - belongsToTask = fieldValues.some((fv) => typeof fv.value === 'string' && fv.value.includes(fileKey)); + // value может быть объектом/массивом (file-поля), а не строкой — + // сериализуем перед поиском ключа + belongsToTask = fieldValues.some((fv) => { + const raw = typeof fv.value === 'string' ? fv.value : JSON.stringify(fv.value ?? ''); + return raw.includes(fileKey); + }); } if (!belongsToTask) { return mcpError(`Файл не относится к задаче ${taskId}`); diff --git a/server/utils/file-tracking.ts b/server/utils/file-tracking.ts index fb19d47..b129aec 100644 --- a/server/utils/file-tracking.ts +++ b/server/utils/file-tracking.ts @@ -78,13 +78,27 @@ interface FileReferenceRow { */ export async function trackFileOnDemand(fileKey: string): Promise { const existing = await db - .select({ id: fileUploads.id }) + .select() .from(fileUploads) .where(eq(fileUploads.fileKey, fileKey)) .limit(1); - if (existing.length > 0) return true; 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;