Files
iistwin/server/routes/sync.routes.ts
Ильяс Султанов a27766a38b perf(tasks): лимиты/пагинация /api/tasks и дельта-синк в SQL
Шаги 0.10 и 0.9 плана production-готовности:
- дефолт limit 50, кап 200; списковые ответы без description
- курсорная пагинация (updated_at, id) + nextCursor
- since-фильтр дельта-синка перенесён в SQL (updated_at >= since)
- embedding pre-count через COUNT(*) вместо выгрузки миллиона строк
- 11 новых юнит-тестов (56/56 зелёные)

Инвариант поиска сохранён: search-ветка /api/tasks?search= не изменена
2026-09-07 22:01:25 +03:00

362 lines
14 KiB
TypeScript
Raw 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 { Router } from "express";
import crypto from "crypto";
import { storage } from "../storage";
import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { tenantIsolation } from "../middleware/tenant.middleware";
import { logAudit } from "../utils/audit";
import { canManageUser } from "../utils/user-access";
import { z } from "zod";
const OFFLINE_TASK_LIMIT = 500;
const initialSyncSchema = z.object({
formIds: z.array(z.number()).optional(),
});
const deltaSyncSchema = z.object({
formIds: z.array(z.number()).optional(),
since: z.string().datetime(),
});
const pushSyncSchema = z.object({
mutations: z.array(z.record(z.any())).default([]),
});
export function registerSyncRoutes(app: import("express").Express): void {
const router = Router();
// Все endpoint'ы синхронизации требуют аутентификации и tenant-изоляции
router.use(authenticateToken, tenantIsolation);
/**
* GET /api/sync/config
* Конфигурация офлайн-режима для текущего пользователя:
* - формы с включённым глобальным offlineCache
* - персональные подписки пользователя
* - автоматизации, разрешённые для офлайн-выполнения
* ?userId=N — конфигурация другого пользователя (только при canManageUser: админ/руководитель)
*/
router.get('/config', async (req: AuthenticatedRequest, res) => {
try {
let userId = req.user!.id;
const organizationId = req.organizationId!;
if (req.query.userId !== undefined) {
const queryUserId = parseInt(String(req.query.userId));
if (isNaN(queryUserId)) {
return res.status(400).json({ success: false, error: 'Некорректный ID пользователя' });
}
if (queryUserId !== userId) {
if (!(await canManageUser(req.user, queryUserId, organizationId))) {
return res.status(403).json({ success: false, error: 'Нет прав на просмотр конфигурации этого пользователя' });
}
userId = queryUserId;
}
}
const [globalForms, subscriptions, automations] = await Promise.all([
storage.getFormsWithOfflineCache(organizationId),
storage.getUserOfflineSubscriptions(userId, organizationId),
storage.getOfflineAutomations(organizationId),
]);
const subscribedFormIds = new Set(subscriptions.map(s => s.formId));
const forms = [
...globalForms.filter(f => !subscribedFormIds.has(f.id)),
...subscriptions.filter(s => s.isActive).map(s => s.formId),
];
const uniqueFormIds = Array.from(new Set(forms.map(f => typeof f === 'number' ? f : f.id)));
const formList = uniqueFormIds.map(id => {
const global = globalForms.find(f => f.id === id);
if (global) return global;
return { id } as const;
});
res.json({
success: true,
forms: formList,
subscriptions,
automations: automations.map(a => ({
id: a.id,
name: a.name,
trigger: a.trigger,
triggerConfig: a.triggerConfig,
runOffline: a.runOffline,
clientCompatible: a.clientCompatible,
})),
});
} catch (err: any) {
console.error('[SYNC] config error:', err);
res.status(500).json({ success: false, error: 'Ошибка получения конфигурации синхронизации' });
}
});
/**
* POST /api/sync/initial
* Начальная полная синхронизация данных для офлайн-работы.
* Возвращает метаданные форм, задачи (до 500 на форму), значения полей,
* справочники и offline-автоматизации.
*/
router.post('/initial', async (req: AuthenticatedRequest, res) => {
try {
const userId = req.user!.id;
const organizationId = req.organizationId!;
const body = initialSyncSchema.parse(req.body);
let formIds = body.formIds;
if (!formIds || formIds.length === 0) {
const [globalForms, subscriptions] = await Promise.all([
storage.getFormsWithOfflineCache(organizationId),
storage.getUserOfflineSubscriptions(userId, organizationId),
]);
const ids = new Set<number>();
globalForms.forEach(f => ids.add(f.id));
subscriptions.filter(s => s.isActive).forEach(s => ids.add(s.formId));
formIds = Array.from(ids);
}
// Проверяем доступ пользователя к каждой форме
const accessibleFormIds: number[] = [];
for (const formId of formIds) {
const canAccess = await storage.canUserAccessForm(userId, formId, organizationId, 'participate');
if (canAccess) accessibleFormIds.push(formId);
}
const forms: any[] = [];
const tasks: any[] = [];
const taskFieldValues: any[] = [];
const tableIds = new Set<number>();
for (const formId of accessibleFormIds) {
const [form, formFields, formStatuses, formTabs, transitions] = await Promise.all([
storage.getForm(formId, organizationId),
storage.getFormFields(formId, organizationId),
storage.getFormStatuses(formId, organizationId),
storage.getFormTabs(formId, organizationId),
storage.getStatusTransitions(formId, organizationId),
]);
if (!form) continue;
// Собираем ID справочников из полей типа table / select
for (const field of formFields) {
const f = field as any;
if (f.type === 'table' && f.linkedFormId) {
tableIds.add(f.linkedFormId);
}
if ((f.type === 'select' || f.type === 'radio-group') && f.options) {
// options может быть строкой JSON
const opts = typeof f.options === 'string' ? JSON.parse(f.options) : f.options;
if (Array.isArray(opts)) {
opts.forEach((o: any) => {
if (o.tableId) tableIds.add(o.tableId);
});
}
}
}
forms.push({
...form,
fields: formFields,
statuses: formStatuses,
tabs: formTabs,
transitions,
});
// Honor the form's offline-cache strategy: 'assigned' fetches only tasks
// assigned to the current user; 'recent' / 'all' fetch the latest/up to limit.
const offlineCache = (form as any)?.offlineCache as { enabled?: boolean; strategy?: string; maxTasks?: number } | null | undefined;
const taskLimit = offlineCache?.maxTasks ?? OFFLINE_TASK_LIMIT;
const taskOptions: { limit: number; assignedTo?: number } = { limit: taskLimit };
if (offlineCache?.strategy === 'assigned') {
taskOptions.assignedTo = userId;
}
const { tasks: formTasks } = await storage.getTasksWithFieldsByFormOptimized(formId, organizationId, taskOptions);
for (const task of formTasks as any[]) {
const fieldValues = task.fieldValues || {};
delete task.fieldValues;
tasks.push(task);
Object.entries(fieldValues).forEach(([fieldId, value]) => {
taskFieldValues.push({ taskId: task.id, fieldId: Number(fieldId), value });
});
}
}
// Загружаем все доступные справочники организации
const dataTables = await storage.getDataTablesByOrganization(organizationId);
const dataTableRows: any[] = [];
for (const table of dataTables) {
const rows = await storage.getDataTableRows(table.id, organizationId);
dataTableRows.push(...rows);
}
const automations = await storage.getOfflineAutomations(organizationId);
const users = await storage.getUsersByOrganization(organizationId);
logAudit({
action: 'sync.initial',
userId,
organizationId,
details: { formCount: forms.length, taskCount: tasks.length },
});
res.json({
success: true,
forms,
tasks,
taskFieldValues,
dataTables,
dataTableRows,
automations: automations.map(a => ({
id: a.id,
name: a.name,
trigger: a.trigger,
triggerConfig: a.triggerConfig,
code: a.code,
runOffline: a.runOffline,
clientCompatible: a.clientCompatible,
})),
users,
});
} catch (err: any) {
console.error('[SYNC] initial error:', err);
res.status(500).json({ success: false, error: 'Ошибка начальной синхронизации' });
}
});
/**
* POST /api/sync/delta
* Дельта-синхронизация: изменения с момента `since`.
*/
router.post('/delta', async (req: AuthenticatedRequest, res) => {
try {
const userId = req.user!.id;
const organizationId = req.organizationId!;
const body = deltaSyncSchema.parse(req.body);
const since = new Date(body.since);
let formIds = body.formIds;
if (!formIds || formIds.length === 0) {
const [globalForms, subscriptions] = await Promise.all([
storage.getFormsWithOfflineCache(organizationId),
storage.getUserOfflineSubscriptions(userId, organizationId),
]);
const ids = new Set<number>();
globalForms.forEach(f => ids.add(f.id));
subscriptions.filter(s => s.isActive).forEach(s => ids.add(s.formId));
formIds = Array.from(ids);
}
const accessibleFormIds: number[] = [];
for (const formId of formIds) {
const canAccess = await storage.canUserAccessForm(userId, formId, organizationId, 'participate');
if (canAccess) accessibleFormIds.push(formId);
}
const changedForms: any[] = [];
const changedTasks: any[] = [];
const changedFieldValues: any[] = [];
const deletedTaskIds: number[] = [];
for (const formId of accessibleFormIds) {
const form = await storage.getForm(formId, organizationId);
if (form && (!form.updatedAt || new Date(form.updatedAt) >= since)) {
const [fields, statuses, tabs, transitions] = await Promise.all([
storage.getFormFields(formId, organizationId),
storage.getFormStatuses(formId, organizationId),
storage.getFormTabs(formId, organizationId),
storage.getStatusTransitions(formId, organizationId),
]);
changedForms.push({ ...form, fields, statuses, tabs, transitions });
}
// Honor the form's offline-cache strategy for delta sync as well.
const offlineCache = (form as any)?.offlineCache as { enabled?: boolean; strategy?: string; maxTasks?: number } | null | undefined;
const taskOptions: { assignedTo?: number; since?: Date } = { since };
if (offlineCache?.strategy === 'assigned') {
taskOptions.assignedTo = userId;
}
// Фильтр по updated_at — на уровне SQL (раньше грузились ВСЕ задачи формы + JS-фильтр)
const { tasks: formTasks } = await storage.getTasksWithFieldsByFormOptimized(formId, organizationId, taskOptions);
for (const task of formTasks as any[]) {
const fieldValues = task.fieldValues || {};
delete task.fieldValues;
changedTasks.push(task);
Object.entries(fieldValues).forEach(([fieldId, value]) => {
changedFieldValues.push({ taskId: task.id, fieldId: Number(fieldId), value });
});
}
// TODO: deleted tasks tracking через task_audit_log
}
// Фильтр по updated_at — на уровне SQL (раньше: полная выгрузка + JS-фильтр)
const changedUsers = await storage.getUsersByOrganization(organizationId, { since });
res.json({
success: true,
since: since.toISOString(),
forms: changedForms,
tasks: changedTasks,
taskFieldValues: changedFieldValues,
deletedTaskIds,
users: changedUsers,
});
} catch (err: any) {
console.error('[SYNC] delta error:', err);
res.status(500).json({ success: false, error: 'Ошибка дельта-синхронизации' });
}
});
/**
* POST /api/sync/push
* Приём offline-мутаций, накопленных клиентом без интернета.
* В текущей версии возвращает accepted-список и conflicts.
*/
router.post('/push', async (req: AuthenticatedRequest, res) => {
try {
const userId = req.user!.id;
const organizationId = req.organizationId!;
const body = pushSyncSchema.parse(req.body);
// LEGACY / NO-OP: the offline queue now replays mutations directly via
// authenticated fetch requests when the client comes back online.
// Keeping this endpoint for backward compatibility with older PWA builds.
const accepted: string[] = [];
const conflicts: Array<{ id: string; reason: string }> = [];
for (const mutation of body.mutations) {
const id = (mutation as any)?.id || crypto.randomUUID();
accepted.push(id);
}
logAudit({
action: 'sync.push',
userId,
organizationId,
details: { accepted: accepted.length, conflicts: conflicts.length },
});
res.json({ success: true, accepted, conflicts });
} catch (err: any) {
console.error('[SYNC] push error:', err);
res.status(500).json({ success: false, error: 'Ошибка приёма offline-мутаций' });
}
});
/**
* GET /api/sync/status
* Служебный статус синхронизации (online/offline, время сервера).
*/
router.get('/status', async (req: AuthenticatedRequest, res) => {
res.json({
success: true,
serverTime: new Date().toISOString(),
online: true,
});
});
app.use('/api/sync', router);
}