fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
351
server/routes/sync.routes.ts
Normal file
351
server/routes/sync.routes.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
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 { 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
|
||||
* - персональные подписки пользователя
|
||||
* - автоматизации, разрешённые для офлайн-выполнения
|
||||
*/
|
||||
router.get('/config', async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
const organizationId = req.organizationId!;
|
||||
|
||||
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: err.message || 'Ошибка получения конфигурации синхронизации' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 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: err.message || 'Ошибка начальной синхронизации' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 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[] = [];
|
||||
const changedUsers: any[] = [];
|
||||
|
||||
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 } = {};
|
||||
if (offlineCache?.strategy === 'assigned') {
|
||||
taskOptions.assignedTo = userId;
|
||||
}
|
||||
const { tasks: formTasks } = await storage.getTasksWithFieldsByFormOptimized(formId, organizationId, taskOptions);
|
||||
const recentTasks = (formTasks as any[]).filter(t => !t.updatedAt || new Date(t.updatedAt) >= since);
|
||||
for (const task of recentTasks) {
|
||||
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
|
||||
}
|
||||
|
||||
const allUsers = await storage.getUsersByOrganization(organizationId);
|
||||
for (const user of allUsers) {
|
||||
if (!user.updatedAt || new Date(user.updatedAt) >= since) {
|
||||
changedUsers.push(user);
|
||||
}
|
||||
}
|
||||
|
||||
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: err.message || 'Ошибка дельта-синхронизации' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 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: err.message || 'Ошибка приёма 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);
|
||||
}
|
||||
Reference in New Issue
Block a user