fix(number-fields): избегаем потери точности длинных чисел

- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
2026-07-07 21:03:40 +03:00
commit 1f5ecb6da4
1089 changed files with 237546 additions and 0 deletions

View File

@@ -0,0 +1,566 @@
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)));
}
}