786 lines
29 KiB
TypeScript
786 lines
29 KiB
TypeScript
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 { eq, and, inArray } from 'drizzle-orm';
|
||
|
||
// ── 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
|
||
heartbeat?: NodeJS.Timeout;
|
||
}
|
||
|
||
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 {
|
||
private connections: Map<string, SSEConnection> = new Map();
|
||
private heartbeatInterval = 30000; // 30 seconds
|
||
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.connections.set(connection.id, connection);
|
||
|
||
// Heartbeat для поддержания соединения
|
||
connection.heartbeat = setInterval(() => {
|
||
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);
|
||
}
|
||
|
||
removeConnection(connectionId: string) {
|
||
const connection = this.connections.get(connectionId);
|
||
if (connection) {
|
||
if (connection.heartbeat) {
|
||
clearInterval(connection.heartbeat);
|
||
}
|
||
this.connections.delete(connectionId);
|
||
}
|
||
}
|
||
|
||
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) {
|
||
console.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(),
|
||
};
|
||
|
||
// Логируем chat-события для диагностики задержек/пропусков
|
||
const isChatEvent = event.type === 'conv_message_created' || event.type === 'conv_message_updated' ||
|
||
event.type === 'conv_message_deleted' || event.type === 'conv_read' || event.type === 'message_created' ||
|
||
event.type === 'message_read';
|
||
if (isChatEvent) {
|
||
const target = event.userId ? `user=${event.userId}` : `org=${event.organizationId}`;
|
||
console.log(`[SSE] publish ${event.type} ${target} conn=${this.connections.size} id=${bufferedEvent.id}`);
|
||
}
|
||
|
||
// Буферизуем событие для восстановления после reconnect
|
||
if (event.userId) {
|
||
this.pushToBuffer(this.userBuffers, event.userId, bufferedEvent);
|
||
} else if (event.organizationId) {
|
||
this.pushToBuffer(this.orgBuffers, event.organizationId, bufferedEvent);
|
||
}
|
||
|
||
let delivered = 0;
|
||
this.connections.forEach((connection, id) => {
|
||
// Проверяем tenant isolation
|
||
if (event.organizationId && connection.organizationId !== event.organizationId) {
|
||
return; // Пропускаем соединения из других организаций
|
||
}
|
||
|
||
// Если событие для конкретного пользователя
|
||
if (event.userId && connection.userId !== event.userId) {
|
||
return;
|
||
}
|
||
|
||
const ok = this.writeEventToConnection(connection, bufferedEvent);
|
||
if (ok) delivered++;
|
||
});
|
||
|
||
if (isChatEvent) {
|
||
console.log(`[SSE] delivered ${event.type} to ${delivered}/${this.connections.size} connection(s)`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Восстанавливает события, пропущенные после 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.connections.size;
|
||
}
|
||
|
||
isUserConnected(userId: number, organizationId: number): boolean {
|
||
for (const conn of this.connections.values()) {
|
||
if (conn.userId === userId && conn.organizationId === organizationId) return true;
|
||
}
|
||
return false;
|
||
}
|
||
}
|
||
|
||
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?: any[];
|
||
usersMap?: Map<number, any>;
|
||
}
|
||
|
||
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': {
|
||
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: any,
|
||
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 as any[]).find((s: any) => 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';
|