Files
iistwin/server/storage/content.storage.ts
Ильяс Султанов 1f5ecb6da4 fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
2026-07-07 21:03:40 +03:00

567 lines
21 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 { forms, formTabs, fieldTemplates, customTabModules, taskTabValues, deviceTokens, userPresence, webPushSubscriptions, customPages, type User, type Organization, type FormTab, type Task, type FieldTemplate, type InsertFieldTemplate, type CustomTabModule, type InsertCustomTabModule, type TaskTabValue, type DeviceToken, type UserPresence, type WebPushSubscription, type CustomPage, type InsertCustomPage } from "@shared/schema";
import { organizationApiKeys, type OrganizationApiKey } from "@shared/schema";
import { automations, type Automation, type InsertAutomation } from "@shared/schema";
import { systemConfig } from "@shared/schema";
import { db } from "../db";
import { eq, and, desc, asc, sql, inArray } from "drizzle-orm";
import crypto from "crypto";
import { hashApiKey, legacySha256Hash } from "../utils/api-key";
import { DataTablesStorage } from "./data-tables.storage";
export class ContentStorage extends DataTablesStorage {
// Field Templates
async getFieldTemplates(organizationId: number): Promise<FieldTemplate[]> {
return await db
.select()
.from(fieldTemplates)
.where(eq(fieldTemplates.organizationId, organizationId))
.orderBy(asc(fieldTemplates.name));
}
async getFieldTemplate(id: number, organizationId: number): Promise<FieldTemplate | undefined> {
const [field] = await db
.select()
.from(fieldTemplates)
.where(and(
eq(fieldTemplates.id, id),
eq(fieldTemplates.organizationId, organizationId)
));
return field || undefined;
}
async getFieldTemplateByCode(code: string, organizationId: number): Promise<FieldTemplate | undefined> {
const [field] = await db
.select()
.from(fieldTemplates)
.where(and(
eq(fieldTemplates.code, code),
eq(fieldTemplates.organizationId, organizationId)
));
return field || undefined;
}
async createFieldTemplate(field: InsertFieldTemplate): Promise<FieldTemplate> {
const [created] = await db
.insert(fieldTemplates)
.values(field)
.returning();
return created;
}
async updateFieldTemplate(id: number, organizationId: number, updates: Partial<FieldTemplate>): Promise<FieldTemplate> {
const [updated] = await db
.update(fieldTemplates)
.set({ ...updates, updatedAt: new Date() })
.where(and(
eq(fieldTemplates.id, id),
eq(fieldTemplates.organizationId, organizationId)
))
.returning();
if (!updated) {
throw new Error("Шаблон поля не найден");
}
return updated;
}
async deleteFieldTemplate(id: number, organizationId: number): Promise<void> {
await db
.delete(fieldTemplates)
.where(and(
eq(fieldTemplates.id, id),
eq(fieldTemplates.organizationId, organizationId)
));
}
// Custom Tab Modules
async getCustomTabModules(organizationId: number): Promise<CustomTabModule[]> {
return await db
.select()
.from(customTabModules)
.where(eq(customTabModules.organizationId, organizationId))
.orderBy(asc(customTabModules.label));
}
async getCustomTabModule(id: number, organizationId: number): Promise<CustomTabModule | undefined> {
const [module] = await db
.select()
.from(customTabModules)
.where(and(
eq(customTabModules.id, id),
eq(customTabModules.organizationId, organizationId)
));
return module || undefined;
}
async getCustomTabModuleByType(type: string, organizationId: number): Promise<CustomTabModule | undefined> {
const [module] = await db
.select()
.from(customTabModules)
.where(and(
eq(customTabModules.type, type),
eq(customTabModules.organizationId, organizationId)
));
return module || undefined;
}
async createCustomTabModule(module: InsertCustomTabModule): Promise<CustomTabModule> {
const [created] = await db
.insert(customTabModules)
.values(module)
.returning();
return created;
}
async updateCustomTabModule(id: number, organizationId: number, updates: Partial<CustomTabModule>): Promise<CustomTabModule> {
const [updated] = await db
.update(customTabModules)
.set({ ...updates, updatedAt: new Date() })
.where(and(
eq(customTabModules.id, id),
eq(customTabModules.organizationId, organizationId)
))
.returning();
if (!updated) {
throw new Error("Модуль вкладки не найден");
}
return updated;
}
async deleteCustomTabModule(id: number, organizationId: number): Promise<void> {
await db
.delete(customTabModules)
.where(and(
eq(customTabModules.id, id),
eq(customTabModules.organizationId, organizationId)
));
}
async getFormIdsByTabModuleType(type: string, organizationId: number): Promise<number[]> {
const moduleCode = `mod_${type}`;
const results = await db
.select({ formId: formTabs.formId })
.from(formTabs)
.innerJoin(forms, eq(formTabs.formId, forms.id))
.where(and(
eq(formTabs.type, type),
eq(formTabs.code, moduleCode),
eq(forms.organizationId, organizationId)
));
return results.map(r => r.formId);
}
async getFormTabByModuleType(formId: number, type: string, organizationId: number): Promise<FormTab | undefined> {
const moduleCode = `mod_${type}`;
const results = await db
.select({ formTab: formTabs })
.from(formTabs)
.innerJoin(forms, eq(formTabs.formId, forms.id))
.where(and(
eq(formTabs.formId, formId),
eq(formTabs.type, type),
eq(formTabs.code, moduleCode),
eq(forms.organizationId, organizationId)
));
return results[0]?.formTab;
}
// Task Tab Values
async getTaskTabValues(taskId: number, tabId: number): Promise<TaskTabValue | undefined> {
const [row] = await db
.select()
.from(taskTabValues)
.where(and(
eq(taskTabValues.taskId, taskId),
eq(taskTabValues.tabId, tabId)
));
return row;
}
async getTaskTabValuesByTask(taskId: number): Promise<TaskTabValue[]> {
return await db
.select()
.from(taskTabValues)
.where(eq(taskTabValues.taskId, taskId));
}
async upsertTaskTabValues(taskId: number, tabId: number, values: Record<string, any>): Promise<TaskTabValue> {
const existing = await this.getTaskTabValues(taskId, tabId);
if (existing) {
const [updated] = await db
.update(taskTabValues)
.set({ values, updatedAt: new Date() })
.where(and(
eq(taskTabValues.taskId, taskId),
eq(taskTabValues.tabId, tabId)
))
.returning();
return updated;
} else {
const [created] = await db
.insert(taskTabValues)
.values({ taskId, tabId, values })
.returning();
return created;
}
}
// =====================
// Device Tokens and User Presence
// =====================
async registerDeviceToken(userId: number, organizationId: number, token: string, platform: string, deviceInfo?: any): Promise<DeviceToken> {
// Сначала удаляем токен если он принадлежит другому пользователю (устройство перешло к другому)
await db.delete(deviceTokens)
.where(and(
eq(deviceTokens.token, token),
sql`${deviceTokens.userId} != ${userId}`
));
// Проверяем, есть ли уже этот токен у текущего пользователя в этой организации
const existing = await db.select().from(deviceTokens)
.where(and(
eq(deviceTokens.userId, userId),
eq(deviceTokens.organizationId, organizationId),
eq(deviceTokens.token, token)
))
.limit(1);
if (existing.length > 0) {
// Просто обновляем lastActiveAt
const [updated] = await db.update(deviceTokens)
.set({ lastActiveAt: new Date(), deviceInfo: deviceInfo || existing[0].deviceInfo })
.where(eq(deviceTokens.id, existing[0].id))
.returning();
return updated;
} else {
// Создаём новую запись
const [created] = await db.insert(deviceTokens)
.values({ userId, organizationId, token, platform, deviceInfo: deviceInfo || null })
.returning();
return created;
}
}
async unregisterDeviceToken(userId: number, organizationId: number, token: string): Promise<void> {
await db.delete(deviceTokens)
.where(and(
eq(deviceTokens.userId, userId),
eq(deviceTokens.organizationId, organizationId),
eq(deviceTokens.token, token)
));
// Пересчитываем presence после удаления устройства
await this.updateUserPresence(userId, organizationId, false);
}
async getDeviceTokensByUser(userId: number, organizationId: number): Promise<DeviceToken[]> {
return await db.select().from(deviceTokens)
.where(and(
eq(deviceTokens.userId, userId),
eq(deviceTokens.organizationId, organizationId)
))
.orderBy(desc(deviceTokens.lastActiveAt));
}
async getDeviceTokensByUsers(userIds: number[], organizationId: number): Promise<DeviceToken[]> {
if (userIds.length === 0) return [];
return await db.select().from(deviceTokens)
.where(and(
inArray(deviceTokens.userId, userIds),
eq(deviceTokens.organizationId, organizationId)
));
}
async deleteInvalidDeviceTokens(tokens: string[]): Promise<number> {
if (tokens.length === 0) return 0;
const result = await db.delete(deviceTokens)
.where(inArray(deviceTokens.token, tokens))
.returning();
return result.length;
}
async updateUserPresence(userId: number, organizationId: number, isOnline: boolean): Promise<UserPresence> {
// Считаем реальное количество зарегистрированных устройств для данной организации
const deviceCount = await db.select({ count: sql<number>`count(*)::int` })
.from(deviceTokens)
.where(and(
eq(deviceTokens.userId, userId),
eq(deviceTokens.organizationId, organizationId)
));
const activeDevices = deviceCount[0]?.count || 0;
const existing = await db.select().from(userPresence)
.where(and(
eq(userPresence.userId, userId),
eq(userPresence.organizationId, organizationId)
))
.limit(1);
if (existing.length > 0) {
const [updated] = await db.update(userPresence)
.set({
isOnline: activeDevices > 0 ? isOnline : false,
lastSeenAt: new Date(),
activeDevices
})
.where(and(
eq(userPresence.userId, userId),
eq(userPresence.organizationId, organizationId)
))
.returning();
return updated;
} else {
const [created] = await db.insert(userPresence)
.values({ userId, organizationId, isOnline: activeDevices > 0 ? isOnline : false, lastSeenAt: new Date(), activeDevices })
.returning();
return created;
}
}
async heartbeatUserPresence(userId: number, organizationId: number): Promise<void> {
// Проверяем наличие устройств в данной организации - нельзя быть online без устройств
const deviceCount = await db.select({ count: sql<number>`count(*)::int` })
.from(deviceTokens)
.where(and(
eq(deviceTokens.userId, userId),
eq(deviceTokens.organizationId, organizationId)
));
const hasDevices = (deviceCount[0]?.count || 0) > 0;
if (!hasDevices) {
// Без устройств нельзя быть online
return;
}
const existing = await db.select().from(userPresence)
.where(and(
eq(userPresence.userId, userId),
eq(userPresence.organizationId, organizationId)
))
.limit(1);
if (existing.length > 0) {
await db.update(userPresence)
.set({ isOnline: true, lastSeenAt: new Date() })
.where(and(
eq(userPresence.userId, userId),
eq(userPresence.organizationId, organizationId)
));
} else {
await db.insert(userPresence)
.values({ userId, organizationId, isOnline: true, lastSeenAt: new Date(), activeDevices: deviceCount[0]?.count || 0 });
}
}
async getUserPresence(userId: number, organizationId: number): Promise<UserPresence | undefined> {
const [result] = await db.select().from(userPresence)
.where(and(
eq(userPresence.userId, userId),
eq(userPresence.organizationId, organizationId)
));
return result;
}
async isUserOnline(userId: number, organizationId: number): Promise<boolean> {
const presence = await this.getUserPresence(userId, organizationId);
if (!presence) return false;
// Consider user offline if last seen more than 5 minutes ago
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
return !!presence.isOnline && !!presence.lastSeenAt && presence.lastSeenAt > fiveMinutesAgo;
}
async getOfflineUsers(userIds: number[], organizationId: number): Promise<number[]> {
if (userIds.length === 0) return [];
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
// Get all presences for this organization
const presences = await db.select().from(userPresence)
.where(and(
inArray(userPresence.userId, userIds),
eq(userPresence.organizationId, organizationId)
));
const onlineUserIds = new Set(
presences
.filter(p => p.isOnline && p.lastSeenAt && p.lastSeenAt > fiveMinutesAgo)
.map(p => p.userId)
);
return userIds.filter(id => !onlineUserIds.has(id));
}
// Web Push Subscriptions
async registerWebPushSubscription(userId: number, organizationId: number, endpoint: string, subscription: string): Promise<void> {
await db.delete(webPushSubscriptions).where(eq(webPushSubscriptions.endpoint, endpoint));
await db.insert(webPushSubscriptions).values({ userId, organizationId, endpoint, subscription });
}
async unregisterWebPushSubscription(endpoint: string): Promise<void> {
await db.delete(webPushSubscriptions).where(eq(webPushSubscriptions.endpoint, endpoint));
}
async getWebPushSubscriptions(userId: number, organizationId: number): Promise<WebPushSubscription[]> {
return await db.select().from(webPushSubscriptions)
.where(and(
eq(webPushSubscriptions.userId, userId),
eq(webPushSubscriptions.organizationId, organizationId)
));
}
async getWebPushSubscriptionsByOrganization(organizationId: number): Promise<WebPushSubscription[]> {
return await db.select().from(webPushSubscriptions)
.where(eq(webPushSubscriptions.organizationId, organizationId));
}
async clearAllWebPushSubscriptions(): Promise<number> {
const deleted = await db.delete(webPushSubscriptions).returning({ id: webPushSubscriptions.id });
return deleted.length;
}
// System Config
async getSystemConfig(key: string): Promise<string | null> {
const [row] = await db.select().from(systemConfig).where(eq(systemConfig.key, key));
return row ? row.value : null;
}
async setSystemConfig(key: string, value: string): Promise<void> {
await db.insert(systemConfig)
.values({ key, value })
.onConflictDoUpdate({ target: systemConfig.key, set: { value, updatedAt: new Date() } });
}
// Organization API Keys (MCP)
async createApiKey(organizationId: number, createdBy: number, label: string): Promise<{ key: string; record: OrganizationApiKey }> {
const rawKey = "wf_" + crypto.randomBytes(32).toString("base64url");
const keyHash = hashApiKey(rawKey);
const keyPrefix = rawKey.substring(0, 10);
const [record] = await db.insert(organizationApiKeys).values({
organizationId, createdBy, label, keyHash, keyPrefix, isLegacy: false,
}).returning();
return { key: rawKey, record };
}
async listApiKeys(organizationId: number): Promise<OrganizationApiKey[]> {
return await db.select().from(organizationApiKeys)
.where(eq(organizationApiKeys.organizationId, organizationId))
.orderBy(desc(organizationApiKeys.createdAt));
}
async deleteApiKey(id: number, organizationId: number): Promise<void> {
await db.delete(organizationApiKeys)
.where(and(eq(organizationApiKeys.id, id), eq(organizationApiKeys.organizationId, organizationId)));
}
async getApiKeyByHash(rawKey: string): Promise<OrganizationApiKey | undefined> {
// hashApiKey throws a descriptive Error if API_KEY_HMAC_SECRET is missing.
// We intentionally do NOT catch that error here — a misconfigured server should
// fail loudly (500) rather than silently reject every key with a 401.
const keyHash = hashApiKey(rawKey);
const [record] = await db.select().from(organizationApiKeys)
.where(and(
eq(organizationApiKeys.keyHash, keyHash),
eq(organizationApiKeys.isActive, true),
eq(organizationApiKeys.isLegacy, false),
));
return record || undefined;
}
async getApiKeyByLegacyHash(rawKey: string): Promise<OrganizationApiKey | undefined> {
const keyHash = legacySha256Hash(rawKey);
const [record] = await db.select().from(organizationApiKeys)
.where(and(
eq(organizationApiKeys.keyHash, keyHash),
eq(organizationApiKeys.isActive, true),
eq(organizationApiKeys.isLegacy, true),
));
return record || undefined;
}
async touchApiKey(id: number): Promise<void> {
await db.update(organizationApiKeys).set({ lastUsedAt: new Date() }).where(eq(organizationApiKeys.id, id));
}
// ── Custom JS Pages ──────────────────────────────────────────────────
async getCustomPages(organizationId: number): Promise<CustomPage[]> {
return db.select().from(customPages)
.where(eq(customPages.organizationId, organizationId))
.orderBy(asc(customPages.name));
}
async getCustomPage(id: number, organizationId: number): Promise<CustomPage | undefined> {
const [row] = await db.select().from(customPages)
.where(and(eq(customPages.id, id), eq(customPages.organizationId, organizationId)));
return row ?? undefined;
}
async getCustomPageBySlug(slug: string, organizationId: number): Promise<CustomPage | undefined> {
const [row] = await db.select().from(customPages)
.where(and(eq(customPages.slug, slug), eq(customPages.organizationId, organizationId)));
return row ?? undefined;
}
async createCustomPage(page: InsertCustomPage): Promise<CustomPage> {
const [row] = await db.insert(customPages).values(page).returning();
return row;
}
async updateCustomPage(id: number, organizationId: number, updates: Partial<InsertCustomPage>): Promise<CustomPage> {
const [row] = await db.update(customPages)
.set({ ...updates, updatedAt: new Date() })
.where(and(eq(customPages.id, id), eq(customPages.organizationId, organizationId)))
.returning();
return row;
}
async deleteCustomPage(id: number, organizationId: number): Promise<void> {
await db.delete(customPages)
.where(and(eq(customPages.id, id), eq(customPages.organizationId, organizationId)));
}
// ── Automations ──────────────────────────────────────────────────────────
async getAutomations(organizationId: number): Promise<Automation[]> {
return db.select().from(automations)
.where(eq(automations.organizationId, organizationId))
.orderBy(asc(automations.name));
}
async getOfflineAutomations(organizationId: number): Promise<Automation[]> {
return db.select().from(automations)
.where(and(
eq(automations.organizationId, organizationId),
eq(automations.isActive, true),
eq(automations.runOffline, true)
))
.orderBy(asc(automations.name));
}
async getAutomation(id: number, organizationId: number): Promise<Automation | undefined> {
const [row] = await db.select().from(automations)
.where(and(eq(automations.id, id), eq(automations.organizationId, organizationId)));
return row ?? undefined;
}
async createAutomation(automation: InsertAutomation): Promise<Automation> {
const [row] = await db.insert(automations).values(automation).returning();
return row;
}
async updateAutomation(id: number, organizationId: number, updates: Partial<InsertAutomation>): Promise<Automation> {
const [row] = await db.update(automations)
.set({ ...updates, updatedAt: new Date() })
.where(and(eq(automations.id, id), eq(automations.organizationId, organizationId)))
.returning();
return row;
}
async deleteAutomation(id: number, organizationId: number): Promise<void> {
await db.delete(automations)
.where(and(eq(automations.id, id), eq(automations.organizationId, organizationId)));
}
}