Files
iistwin/server/routes/shared.ts
Ильяс Султанов 75ec0f25bb chore(repo): дедуп formatAmount, пины образов, дефолты MinIO, доля any
Шаги 2.3 и 2.4 плана production-готовности:
- lib/format.ts formatAmount вместо 2 локальных копий
- пины тегов minio/rclone/ollama/traccar/pgvector + osrm по digest
- minioadmin без дефолта (env required, dev-подсказки в .env.example)
- 28 any в auto-transitions/shared типизированы из @shared/schema
- удалён _tablefield_orig.txt
- check/lint/build чисто, vitest 104/104
2026-09-08 01:07:55 +03:00

783 lines
29 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 rateLimit, { ipKeyGenerator } from 'express-rate-limit';
import type { Request } from 'express';
import { storage } from '../storage';
import { logAudit } from '../utils/audit';
import { db } from '../db';
import { formatUserName } from '../utils/formatUserName';
import { tasks, forms, roles } from '@shared/schema';
import type { FormStatus, Task } from '@shared/schema';
import { eq, and, inArray } from 'drizzle-orm';
import { SseConnectionIndex } from '../utils/sse-connection-index';
import { logger } from '../utils/logger';
const sseLog = logger('sse');
// ── Rate-limit helpers ───────────────────────────────────────────────────────
// In offices with a single public IP and many users, IP-only rate limiting
// puts every user into the same bucket. We extract the caller identity from
// the JWT (Authorization header or access_token cookie) so each user/bot has
// their own bucket while still keeping the IP as a fallback namespace.
function extractJwtIdentifier(token: string): string | null {
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
if (typeof payload.userId === 'number') return `u:${payload.userId}`;
if (typeof payload.botId === 'number') return `b:${payload.botId}`;
if (typeof payload.superAdminId === 'number') return `sa:${payload.superAdminId}`;
return null;
} catch {
return null;
}
}
function getBearerToken(req: Request): string | null {
const auth = req.headers['authorization'];
if (auth && typeof auth === 'string' && auth.startsWith('Bearer ')) {
return auth.slice(7);
}
return null;
}
function getCookieToken(req: Request): string | null {
const cookieHeader = req.headers['cookie'];
if (!cookieHeader || typeof cookieHeader !== 'string') return null;
const match = cookieHeader.match(/\baccess_token=([^;]+)/);
return match ? decodeURIComponent(match[1]) : null;
}
function buildRateLimitKey(req: Request): string {
const ip = ipKeyGenerator(req.ip ?? 'unknown');
const token = getBearerToken(req) || getCookieToken(req);
if (token) {
const id = extractJwtIdentifier(token);
if (id) return `${ip}:${id}`;
// API keys and other opaque tokens: include the first 32 chars so
// different keys do not share a bucket.
return `${ip}:${token.slice(0, 32)}`;
}
return ip;
}
// Event Bus для SSE соединений
export interface SSEConnection {
id: string;
userId: number;
organizationId: number;
res: any; // Express Response object
}
export interface Event {
type: string;
data: any;
organizationId?: number;
userId?: number;
taskId?: number;
}
export interface BufferedEvent extends Event {
id: string;
timestamp: number;
}
export class EventBus {
// Индекс соединений по организации/пользователю — publishEvent не перебирает все соединения
private index = new SseConnectionIndex<SSEConnection>();
private heartbeatInterval = 30000; // 30 seconds
private heartbeatTimer: NodeJS.Timeout | null = null;
private eventIdCounter = 0;
// Буферы последних событий для восстановления после reconnect
private userBuffers = new Map<number, BufferedEvent[]>();
private orgBuffers = new Map<number, BufferedEvent[]>();
private readonly BUFFER_SIZE = 500;
addConnection(connection: SSEConnection) {
this.index.add(connection);
this.ensureHeartbeatTimer();
}
removeConnection(connectionId: string) {
this.index.remove(connectionId);
// Когда соединений нет, останавливаем heartbeat-таймер, чтобы не будить event loop впустую
if (this.index.size === 0 && this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
// Один глобальный heartbeat-таймер на все соединения (вместо таймера на каждое соединение)
private ensureHeartbeatTimer() {
if (this.heartbeatTimer) return;
this.heartbeatTimer = setInterval(() => {
this.index.forEach((connection) => {
try {
connection.res.write(`: heartbeat\n\n`);
const resAny = connection.res as any;
if (typeof resAny.flush === 'function') {
resAny.flush();
}
} catch (error) {
this.removeConnection(connection.id);
}
});
}, this.heartbeatInterval);
// unref, чтобы таймер не удерживал процесс (и не мешал тестам завершаться)
this.heartbeatTimer.unref?.();
}
private generateEventId(): string {
return `${Date.now()}-${++this.eventIdCounter}`;
}
private pushToBuffer(map: Map<number, BufferedEvent[]>, key: number, event: BufferedEvent) {
let buf = map.get(key);
if (!buf) {
buf = [];
map.set(key, buf);
}
buf.push(event);
if (buf.length > this.BUFFER_SIZE) {
buf.shift();
}
}
private writeEventToConnection(connection: SSEConnection, event: BufferedEvent): boolean {
try {
const eventData = JSON.stringify(event.data);
connection.res.write(`id: ${event.id}\n`);
connection.res.write(`event: ${event.type}\n`);
connection.res.write(`data: ${eventData}\n\n`);
// Force immediate delivery for SSE streams (Express/Node may buffer writes)
const resAny = connection.res as any;
if (typeof resAny.flush === 'function') {
resAny.flush();
}
return true;
} catch (error) {
sseLog.error(`Failed to send event to connection ${connection.id}:`, error);
this.removeConnection(connection.id);
return false;
}
}
publishEvent(event: Event) {
// Also trigger an offline sync via silent web push when a task changes.
// This wakes up closed PWAs so they can pull the latest data via delta sync.
if (event.type === 'task_updated' && event.organizationId) {
import('../utils/pushSyncNeeded')
.then(({ pushSyncNeeded }) => pushSyncNeeded(event.organizationId!).catch(() => {}))
.catch(() => {});
}
const bufferedEvent: BufferedEvent = {
...event,
id: this.generateEventId(),
timestamp: Date.now(),
};
// Буферизуем событие для восстановления после reconnect
if (event.userId) {
this.pushToBuffer(this.userBuffers, event.userId, bufferedEvent);
} else if (event.organizationId) {
this.pushToBuffer(this.orgBuffers, event.organizationId, bufferedEvent);
}
// Рассылка только соединениям нужной организации/пользователя (tenant isolation в индексе)
for (const connection of this.index.getForEventTarget(event.organizationId, event.userId)) {
this.writeEventToConnection(connection, bufferedEvent);
}
}
/**
* Восстанавливает события, пропущенные после reconnect.
* Отправляет пользователю его персональные события + organization-wide события.
*/
replayBufferedEvents(connection: SSEConnection, lastEventId?: string) {
const userBuf = this.userBuffers.get(connection.userId) ?? [];
const orgBuf = this.orgBuffers.get(connection.organizationId) ?? [];
// Объединяем без дубликатов через Map и сортируем по id (monotonic строка вида timestamp-counter)
const seen = new Map<string, BufferedEvent>();
for (const e of userBuf) seen.set(e.id, e);
for (const e of orgBuf) seen.set(e.id, e);
const combined = Array.from(seen.values()).sort((a, b) => a.id.localeCompare(b.id));
let startIdx = 0;
if (lastEventId) {
const idx = combined.findIndex(e => e.id === lastEventId);
if (idx !== -1) {
startIdx = idx + 1;
}
// Если lastEventId не найден в буфере, значит он слишком старый —
// отправляем всё, что есть (fallback, клиент сам разберёт дубликаты).
}
const eventsToReplay = combined.slice(startIdx);
if (eventsToReplay.length === 0) return;
for (const event of eventsToReplay) {
// Tenant isolation
if (event.organizationId && event.organizationId !== connection.organizationId) continue;
// User-specific events only for target user
if (event.userId && event.userId !== connection.userId) continue;
this.writeEventToConnection(connection, event);
}
}
getActiveConnections() {
return this.index.size;
}
isUserConnected(userId: number, organizationId: number): boolean {
return this.index.isUserConnected(userId, organizationId);
}
}
export const eventBus = new EventBus();
/**
* Публикует SSE-событие «notification» для конкретного пользователя.
* Используется воркерами (напоминания и т.д.) для real-time обновления бейджа.
*/
export function publishNotificationSSE(userId: number, organizationId: number, data: Record<string, unknown>) {
eventBus.publishEvent({
type: 'notification',
data,
organizationId,
userId,
});
}
// Rate limiting configurations
// Лимит для входа и регистрации — 20 попыток за 15 минут на IP
export const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 20,
message: { error: 'Слишком много попыток входа. Попробуйте через 15 минут.' },
standardHeaders: true,
legacyHeaders: false,
handler: (req, res) => {
logAudit({
action: 'rate_limit.auth',
details: { path: req.path, method: req.method },
ip: req.ip ?? null,
userAgent: req.headers['user-agent'] ?? null,
});
res.status(429).json({ error: 'Слишком много попыток входа. Попробуйте через 15 минут.' });
},
});
// Отдельный лимит для обновления токенов — приложение вызывает его автоматически,
// поэтому лимит значительно выше чтобы не мешать нормальной работе
export const refreshLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 120,
message: { error: 'Слишком много запросов обновления токена. Попробуйте позже.' },
standardHeaders: true,
legacyHeaders: false,
});
// Общий API лимит — 1500 запросов за 15 минут
// Auth, SSE и health check пропускаются — у них свои лимитеры
// Для аутентифицированных запросов ключ включает идентификатор пользователя
// из JWT (Authorization или cookie), чтобы пользователи за одним NAT не делили
// между собой один лимит.
export const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 1500,
message: { error: 'Превышен лимит запросов к API. Попробуйте через несколько минут.' },
standardHeaders: true,
legacyHeaders: false,
keyGenerator: buildRateLimitKey,
skip: (req) => {
if (req.method === 'HEAD') return true;
// req.path внутри app.use('/api', ...) — без префикса /api
const p = req.path;
return (
p === '/health' ||
p.startsWith('/auth/') ||
p.startsWith('/bot/auth/') ||
p === '/events'
);
},
handler: (req, res) => {
logAudit({
action: 'rate_limit.api',
details: { path: req.path, method: req.method },
ip: req.ip ?? null,
userAgent: req.headers['user-agent'] ?? null,
});
res.status(429).json({ error: 'Превышен лимит запросов к API. Попробуйте через несколько минут.' });
},
});
// SSE соединения — не более 600 подключений за час на пользователя/IP.
// Как и для API, разделяем бакеты по JWT-идентификатору, чтобы десяток
// человек за одним офисным NAT не исчерпал общий лимит.
export const sseLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 600,
message: { error: 'Превышен лимит SSE-подключений. Попробуйте через час.' },
standardHeaders: true,
legacyHeaders: false,
keyGenerator: buildRateLimitKey,
handler: (req, res) => {
logAudit({
action: 'rate_limit.sse',
details: { path: req.path },
ip: req.ip ?? null,
userAgent: req.headers['user-agent'] ?? null,
});
res.status(429).json({ error: 'Превышен лимит SSE-подключений. Попробуйте через час.' });
},
});
export interface SystemFieldContext {
statuses?: FormStatus[];
usersMap?: Map<number, { firstName?: string | null; lastName?: string | null; middleName?: string | null; email?: string | null } | undefined>;
}
function safeParseOptions(opts: unknown): unknown {
if (Array.isArray(opts)) return opts;
if (typeof opts === 'string') {
try { return JSON.parse(opts); } catch { return undefined; }
}
if (typeof opts === 'object' && opts !== null) return opts;
return undefined;
}
export interface FieldTitleContext {
usersMap?: Map<number, any>;
rolesMap?: Map<number, any>;
taskTitleMap?: Map<number, string>;
/** tableId -> rowId -> displayValue */
tableRowMap?: Map<number, Map<number, string>>;
/** fieldId -> options array (for select/radio-group resolution) */
fieldOptionsMap?: Map<number, Array<{ label?: string; value?: string; id?: string }>>;
}
export function formatFieldValueForTitle(val: any, fieldType?: string, ctx?: FieldTitleContext, fieldOptions?: unknown): string {
if (val === null || val === undefined) return '';
if (val === '__auto_prolongation__') return val;
if (process.env.NODE_ENV !== 'production') {
console.log('[formatFieldValueForTitle] fieldType:', fieldType, 'val:', JSON.stringify(val), 'fieldOptions:', JSON.stringify(fieldOptions));
}
// For task fields, try to resolve title from map before treating as primitive
if (fieldType === 'task' && ctx?.taskTitleMap) {
const id = typeof val === 'string' ? parseInt(val, 10) : Number(val);
if (!isNaN(id) && ctx.taskTitleMap.has(id)) {
return ctx.taskTitleMap.get(id)!;
}
}
const rawStr = String(val);
switch (fieldType) {
case 'user': {
// Мультивыбор (allow_multiple): массив ["user:5", "role:2"] — имена через запятую
let parsedUserVal: any = val;
if (typeof val === 'string' && val.startsWith('[')) {
try { parsedUserVal = JSON.parse(val); } catch { parsedUserVal = val; }
}
if (Array.isArray(parsedUserVal)) {
return parsedUserVal
.map((entry: unknown) => formatFieldValueForTitle(String(entry), 'user', ctx))
.filter(Boolean)
.join(', ');
}
if (rawStr.startsWith('user:')) {
const uid = Number(rawStr.replace('user:', ''));
const user = ctx?.usersMap?.get(uid) ?? (ctx?.usersMap as any)?.get(String(uid));
if (user) {
return formatUserName(user);
}
return rawStr;
}
if (rawStr.startsWith('role:')) {
const rid = Number(rawStr.replace('role:', ''));
const role = ctx?.rolesMap?.get(rid) ?? (ctx?.rolesMap as any)?.get(String(rid));
if (role) {
return `Роль: ${role.name || rawStr}`;
}
return rawStr;
}
const uid = Number(rawStr);
if (!isNaN(uid)) {
const user = ctx?.usersMap?.get(uid) ?? (ctx?.usersMap as any)?.get(String(uid));
if (user) {
return formatUserName(user);
}
}
return rawStr;
}
case 'role': {
if (rawStr.startsWith('role:')) {
const rid = Number(rawStr.replace('role:', ''));
const role = ctx?.rolesMap?.get(rid) ?? (ctx?.rolesMap as any)?.get(String(rid));
if (role) {
return role.name || rawStr;
}
return rawStr;
}
const rid = Number(rawStr);
if (!isNaN(rid)) {
const role = ctx?.rolesMap?.get(rid) ?? (ctx?.rolesMap as any)?.get(String(rid));
if (role) {
return role.name || rawStr;
}
}
return rawStr;
}
case 'table': {
if (process.env.NODE_ENV !== 'production') {
console.log('[formatFieldValueForTitle] table case — ctx?.tableRowMap size:', ctx?.tableRowMap?.size, 'fieldOptions:', JSON.stringify(fieldOptions));
}
// Drizzle may return jsonb values as JSON strings in some query paths
let parsedVal: any = val;
if (typeof val === 'string') {
try { parsedVal = JSON.parse(val); } catch { parsedVal = val; }
}
// Handle array (multi-select)
if (Array.isArray(parsedVal)) {
return parsedVal
.map((v) => formatFieldValueForTitle(v, fieldType, ctx, fieldOptions))
.filter(Boolean)
.join(', ');
}
// Try to resolve via tableRowMap first
if (ctx?.tableRowMap && fieldOptions) {
const parsedOpts = safeParseOptions(fieldOptions);
const tableOpts = (typeof parsedOpts === 'object' && parsedOpts !== null && !Array.isArray(parsedOpts))
? parsedOpts as { tableId?: number }
: undefined;
const tableId = tableOpts?.tableId;
let rowId: number | undefined;
if (typeof parsedVal === 'object' && parsedVal !== null && typeof parsedVal.rowId === 'number') {
rowId = parsedVal.rowId;
} else {
const num = Number(rawStr);
if (!isNaN(num)) rowId = num;
}
if (process.env.NODE_ENV !== 'production') {
console.log('[formatFieldValueForTitle] table resolved — tableId:', tableId, 'rowId:', rowId, 'hasTable:', tableId !== undefined ? ctx.tableRowMap.has(tableId) : false);
}
if (tableId !== undefined && rowId !== undefined && ctx.tableRowMap.has(tableId)) {
const displayValue = ctx.tableRowMap.get(tableId)!.get(rowId);
if (process.env.NODE_ENV !== 'production') {
console.log('[formatFieldValueForTitle] table displayValue:', displayValue);
}
if (displayValue !== undefined) return displayValue;
}
}
if (typeof parsedVal === 'object' && parsedVal !== null && typeof parsedVal.rowId === 'number') {
return `#${parsedVal.rowId}`;
}
return rawStr;
}
case 'select':
case 'radio-group': {
const parsedOpts = safeParseOptions(fieldOptions);
if (process.env.NODE_ENV !== 'production') {
console.log('[formatFieldValueForTitle] select/radio case — parsedOpts isArray:', Array.isArray(parsedOpts), 'val:', JSON.stringify(val));
}
if (Array.isArray(parsedOpts) && (typeof val === 'string' || typeof val === 'number')) {
const strVal = String(val);
const opt = (parsedOpts as Array<{ label?: string; value?: string; id?: string }>)
.find(o => String(o.value ?? o.id) === strVal);
if (process.env.NODE_ENV !== 'production') {
console.log('[formatFieldValueForTitle] select found opt:', opt);
}
if (opt?.label) return opt.label;
}
return rawStr;
}
case 'toggle':
case 'checkbox': {
if (typeof val === 'boolean') return val ? 'Да' : 'Нет';
if (rawStr === 'true') return 'Да';
if (rawStr === 'false') return 'Нет';
return rawStr;
}
case 'checklist': {
if (Array.isArray(val)) {
const checkedCount = val.filter((it: any) => it?.checked).length;
return `Выполнено ${checkedCount}`;
}
return rawStr;
}
case 'geo': {
if (typeof val === 'object' && val !== null) {
return val.address || val.name || val.value || rawStr;
}
return rawStr;
}
default:
break;
}
// Primitives (fallback for non-object types)
if (typeof val !== 'object') return rawStr;
// Arrays (multi-select, file, etc.)
if (Array.isArray(val)) {
return val
.map((v) => formatFieldValueForTitle(v, fieldType, ctx))
.filter(Boolean)
.join(', ');
}
// Objects by field type
switch (fieldType) {
case 'contract-number':
return val.value || val.number || '';
case 'contact': {
const parts: string[] = [];
if (val.name) parts.push(val.name);
if (val.position) parts.push(`(${val.position})`);
if (val.phones?.length) parts.push(val.phones.join(', '));
if (val.emails?.length) parts.push(val.emails.join(', '));
return parts.join(' ');
}
case 'company':
return val.value || val.name || val.shortName || val.fullName || '';
case 'task':
return val.taskId || val.id || '';
case 'select':
return val.label || val.value || '';
case 'file':
return val.name || val.url || '';
default:
return val.value || val.label || val.name || val.title || val.text || JSON.stringify(val);
}
}
export async function resolveTaskFieldTitles(
valueByFieldId: Map<number, any>,
fields: Array<{ id: number; type: string }>,
organizationId: number
): Promise<Map<number, string>> {
const taskIds: number[] = [];
for (const field of fields) {
if (field.type === 'task') {
const val = valueByFieldId.get(field.id);
if (val == null || val === '') continue;
let id: number | undefined;
if (typeof val === 'number') id = val;
else if (typeof val === 'string') {
const n = parseInt(val, 10);
if (!isNaN(n)) id = n;
else {
try {
const parsed = JSON.parse(val);
if (parsed && typeof parsed.taskId === 'number') id = parsed.taskId;
else if (parsed && typeof parsed.id === 'number') id = parsed.id;
} catch { /* ignore */ }
}
} else if (typeof val === 'object' && val !== null) {
const obj = val as any;
if (typeof obj.taskId === 'number') id = obj.taskId;
else if (typeof obj.id === 'number') id = obj.id;
}
if (id !== undefined && !isNaN(id)) taskIds.push(id);
}
}
if (taskIds.length === 0) return new Map();
const rows = await db
.select({ id: tasks.id, title: tasks.title })
.from(tasks)
.innerJoin(forms, eq(tasks.formId, forms.id))
.where(and(inArray(tasks.id, taskIds), eq(forms.organizationId, organizationId)));
const map = new Map<number, string>();
for (const row of rows) {
map.set(row.id, row.title);
}
return map;
}
export async function buildSystemFieldValues(
task: Task,
orgId: number,
ctx?: SystemFieldContext
): Promise<Map<string, string>> {
const map = new Map<string, string>();
map.set('_id', String(task.id));
map.set('_number', String(task.id));
map.set('_due_date', task.dueDate ? new Date(task.dueDate).toLocaleDateString('ru-RU') : '');
// Use preloaded statuses if provided, otherwise fetch
const statuses = ctx?.statuses
?? (task.currentStatusId && task.formId ? await storage.getFormStatuses(task.formId, orgId) : []);
const status = statuses.find((s) => s.id === task.currentStatusId);
map.set('_status', status?.name ?? '');
if (task.assignedTo) {
const user = ctx?.usersMap
? ctx.usersMap.get(task.assignedTo)
: await storage.getUser(task.assignedTo);
map.set('_assignee', formatUserName(user));
} else {
map.set('_assignee', '');
}
if (task.createdBy) {
const user = ctx?.usersMap
? ctx.usersMap.get(task.createdBy)
: await storage.getUser(task.createdBy);
map.set('_author', formatUserName(user));
} else {
map.set('_author', '');
}
return map;
}
/**
* Пересчитывает названия всех задач формы на основе её titleTemplate.
* Возвращает статистику: сколько обновлено, общее количество, ошибки.
*/
export async function recalculateTaskTitles(
formId: number,
organizationId: number
): Promise<{
updated: number;
total: number;
errors: Array<{ taskId: number; title: string; error: string }>;
}> {
const formTasks = await storage.getTasksByForm(formId, organizationId);
if (formTasks.length === 0) {
return { updated: 0, total: 0, errors: [] };
}
const form = await storage.getForm(formId, organizationId);
if (!form || !form.titleTemplate) {
return { updated: 0, total: formTasks.length, errors: [] };
}
const [allFormFields, titleStatuses, titleUsers, titleRoles] = await Promise.all([
storage.getFormFields(formId, organizationId),
storage.getFormStatuses(formId, organizationId),
storage.getUsersByOrganization(organizationId),
db.select().from(roles).where(eq(roles.organizationId, organizationId)),
]);
const fieldCodeMap = new Map(allFormFields.map((f) => [f.code, f]));
const usersMap = new Map(titleUsers.map((u) => [u.id, u]));
const rolesMap = new Map(titleRoles.map((r) => [r.id, r]));
// Загружаем справочники для полей типа 'table' чтобы резолвить ID в названия при формировании title
const tableRowMap = await buildTableRowMap(allFormFields, organizationId);
let updated = 0;
const errors: Array<{ taskId: number; title: string; error: string }> = [];
for (const task of formTasks) {
try {
const savedValues = await storage.getTaskFieldValues(task.id, organizationId);
const valueByFieldId = new Map(savedValues.map((v) => [v.fieldId, v.value]));
const sysValues = await buildSystemFieldValues(task, organizationId, { statuses: titleStatuses, usersMap });
const taskTitleMap = await resolveTaskFieldTitles(valueByFieldId, allFormFields, organizationId);
const titleCtx: FieldTitleContext = { usersMap, rolesMap, taskTitleMap, tableRowMap };
const computedTitle = (form.titleTemplate as string).replace(/\{\{([^}]+)\}\}/g, (_, code: string) => {
if (sysValues.has(code)) return sysValues.get(code)!;
const fld = fieldCodeMap.get(code);
if (!fld) return "";
const val = valueByFieldId.get(fld.id);
if (val === null || val === undefined) return "";
if (val === "__auto_prolongation__" && (fld.type === "date" || fld.type === "datetime")) {
return fld.autoProlongationLabel || "Автопролонгация";
}
return formatFieldValueForTitle(val, fld.type, titleCtx, fld.options);
});
if (computedTitle !== task.title) {
await storage.updateTask(task.id, organizationId, { title: computedTitle });
updated++;
}
} catch (err: any) {
errors.push({ taskId: task.id, title: task.title || "", error: err.message || "Ошибка пересчёта" });
}
}
return { updated, total: formTasks.length, errors };
}
/** Загружает справочники для полей типа 'table' и строит tableRowMap */
export async function buildTableRowMap(
fields: Array<{ type?: string; options?: unknown }>,
organizationId: number
): Promise<Map<number, Map<number, string>>> {
const tableRowMap = new Map<number, Map<number, string>>();
const tableFields = fields.filter((f: any) => {
if (f.type !== 'table') return false;
const parsed = safeParseOptions(f.options);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) && (parsed as any).tableId;
});
if (process.env.NODE_ENV !== 'production') {
console.log('[buildTableRowMap] tableFields count:', tableFields.length, 'fieldIds:', tableFields.map((f: any) => f.id));
}
const uniqueTableIds = new Set<number>();
for (const f of tableFields) {
const parsed = safeParseOptions(f.options);
const tableId = (parsed as any).tableId as number;
if (tableId) uniqueTableIds.add(tableId);
}
for (const tableId of uniqueTableIds) {
const fullTable = await storage.getDataTableFull(tableId, organizationId);
if (!fullTable) continue;
const columns = (fullTable.columns as Array<{ name: string }>) || [];
const rows = (fullTable.rows as Array<{ id: number; values: string[] }>) || [];
const displayColumn = (tableFields.find((f: any) => (f.options as any).tableId === tableId)?.options as any)?.displayColumn || columns[0]?.name;
const colIndex = columns.findIndex((c) => c.name === displayColumn);
const rowMap = new Map<number, string>();
for (const row of rows) {
const displayValue = colIndex >= 0 ? row.values[colIndex] : row.values[0];
rowMap.set(row.id, displayValue || `#${row.id}`);
}
tableRowMap.set(tableId, rowMap);
}
return tableRowMap;
}
// Re-exports from upload utils for backward compatibility
export {
uploadsDir,
tmpUploadDir,
EXT_TO_MIME,
EXT_MAGIC,
IMAGE_EXTENSIONS,
IMAGE_MAX_SIZE,
DOC_MAX_SIZE,
getFileExt,
validateFilename,
isMimeConsistentWithExt,
checkMagicBytes,
getSizeLimit,
upload,
extractFileUrls,
deleteFileByUrl,
deleteUploadedFile,
deleteRemovedFiles,
pendingUploads,
PENDING_UPLOAD_TTL_MS,
getPendingKey,
getActivePendingUploads,
addPendingUpload,
commitPendingUploads,
sweepPendingUploads,
type PendingUploadEntry,
} from '../utils/upload';