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, type ApiKeyScopes } 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 { return await db .select() .from(fieldTemplates) .where(eq(fieldTemplates.organizationId, organizationId)) .orderBy(asc(fieldTemplates.name)); } async getFieldTemplate(id: number, organizationId: number): Promise { 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 { 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 { const [created] = await db .insert(fieldTemplates) .values(field) .returning(); return created; } async updateFieldTemplate(id: number, organizationId: number, updates: Partial): Promise { 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 { await db .delete(fieldTemplates) .where(and( eq(fieldTemplates.id, id), eq(fieldTemplates.organizationId, organizationId) )); } // Custom Tab Modules async getCustomTabModules(organizationId: number): Promise { return await db .select() .from(customTabModules) .where(eq(customTabModules.organizationId, organizationId)) .orderBy(asc(customTabModules.label)); } async getCustomTabModule(id: number, organizationId: number): Promise { 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 { 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 { const [created] = await db .insert(customTabModules) .values(module) .returning(); return created; } async updateCustomTabModule(id: number, organizationId: number, updates: Partial): Promise { 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 { await db .delete(customTabModules) .where(and( eq(customTabModules.id, id), eq(customTabModules.organizationId, organizationId) )); } async getFormIdsByTabModuleType(type: string, organizationId: number): Promise { 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 { 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 { const [row] = await db .select() .from(taskTabValues) .where(and( eq(taskTabValues.taskId, taskId), eq(taskTabValues.tabId, tabId) )); return row; } async getTaskTabValuesByTask(taskId: number): Promise { return await db .select() .from(taskTabValues) .where(eq(taskTabValues.taskId, taskId)); } async upsertTaskTabValues(taskId: number, tabId: number, values: Record): Promise { 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 { // Сначала удаляем токен если он принадлежит другому пользователю (устройство перешло к другому) 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 { 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 { 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 { 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 { 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 { // Считаем реальное количество зарегистрированных устройств для данной организации const deviceCount = await db.select({ count: sql`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 { // Проверяем наличие устройств в данной организации - нельзя быть online без устройств const deviceCount = await db.select({ count: sql`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 { 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 { 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 { 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)); } async updateUserActiveChat( userId: number, organizationId: number, type: 'messenger' | 'task', id: number, visible: boolean ): Promise { 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({ activeChatType: type, activeChatId: id, activeChatVisible: visible, activeChatUpdatedAt: new Date(), 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: 0, activeChatType: type, activeChatId: id, activeChatVisible: visible, activeChatUpdatedAt: new Date(), }); } } async clearUserActiveChat(userId: number, organizationId: number): Promise { await db.update(userPresence) .set({ activeChatType: null, activeChatId: null, activeChatVisible: false, activeChatUpdatedAt: new Date(), }) .where(and( eq(userPresence.userId, userId), eq(userPresence.organizationId, organizationId) )); } async getUserActiveChat(userId: number, organizationId: number): Promise<{ type: 'messenger' | 'task' | null; id: number | null; visible: boolean; updatedAt: Date | null; } | null> { const [presence] = await db.select({ activeChatType: userPresence.activeChatType, activeChatId: userPresence.activeChatId, activeChatVisible: userPresence.activeChatVisible, activeChatUpdatedAt: userPresence.activeChatUpdatedAt, }).from(userPresence) .where(and( eq(userPresence.userId, userId), eq(userPresence.organizationId, organizationId) )); if (!presence) return null; return { type: (presence.activeChatType as 'messenger' | 'task') || null, id: presence.activeChatId ?? null, visible: presence.activeChatVisible ?? false, updatedAt: presence.activeChatUpdatedAt ?? null, }; } // Web Push Subscriptions async registerWebPushSubscription(userId: number, organizationId: number, endpoint: string, subscription: string): Promise { await db.delete(webPushSubscriptions).where(eq(webPushSubscriptions.endpoint, endpoint)); await db.insert(webPushSubscriptions).values({ userId, organizationId, endpoint, subscription }); } async unregisterWebPushSubscription(endpoint: string): Promise { await db.delete(webPushSubscriptions).where(eq(webPushSubscriptions.endpoint, endpoint)); } async getWebPushSubscriptions(userId: number, organizationId: number): Promise { return await db.select().from(webPushSubscriptions) .where(and( eq(webPushSubscriptions.userId, userId), eq(webPushSubscriptions.organizationId, organizationId) )); } async getWebPushSubscriptionsByOrganization(organizationId: number): Promise { return await db.select().from(webPushSubscriptions) .where(eq(webPushSubscriptions.organizationId, organizationId)); } async clearAllWebPushSubscriptions(): Promise { const deleted = await db.delete(webPushSubscriptions).returning({ id: webPushSubscriptions.id }); return deleted.length; } // System Config async getSystemConfig(key: string): Promise { const [row] = await db.select().from(systemConfig).where(eq(systemConfig.key, key)); return row ? row.value : null; } async setSystemConfig(key: string, value: string): Promise { 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, scopes?: ApiKeyScopes | null, botId?: number | null): 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, // NULL = полный доступ (legacy-поведение) scopes: scopes ?? null, botId: botId ?? null, }).returning(); return { key: rawKey, record }; } // Ключ, привязанный к боту (1:1), с проверкой принадлежности организации. async getApiKeyByBotId(botId: number, organizationId: number): Promise { const [record] = await db.select().from(organizationApiKeys) .where(and(eq(organizationApiKeys.botId, botId), eq(organizationApiKeys.organizationId, organizationId))); return record || undefined; } // Активация/деактивация ключа (запись сохраняется). async setApiKeyActive(id: number, organizationId: number, isActive: boolean): Promise { await db.update(organizationApiKeys) .set({ isActive }) .where(and(eq(organizationApiKeys.id, id), eq(organizationApiKeys.organizationId, organizationId))); } // Отвязка ключа от бота (bot_id = NULL). // Нужна при regenerate: частичный уникальный индекс по bot_id не даёт // создать новый ключ бота, пока старый хранит привязку. async detachApiKeyFromBot(id: number, organizationId: number): Promise { await db.update(organizationApiKeys) .set({ botId: null }) .where(and(eq(organizationApiKeys.id, id), eq(organizationApiKeys.organizationId, organizationId))); } // Обновление label/scopes ключа с проверкой принадлежности организации. // Возвращает undefined, если ключ не найден в этой организации. async updateApiKey(id: number, organizationId: number, data: { label?: string; scopes?: ApiKeyScopes | null }): Promise { const updates: Partial> = {}; if (data.label !== undefined) updates.label = data.label; if (data.scopes !== undefined) updates.scopes = data.scopes; if (Object.keys(updates).length === 0) { const [existing] = await db.select().from(organizationApiKeys) .where(and(eq(organizationApiKeys.id, id), eq(organizationApiKeys.organizationId, organizationId))); return existing || undefined; } const [updated] = await db.update(organizationApiKeys) .set(updates) .where(and(eq(organizationApiKeys.id, id), eq(organizationApiKeys.organizationId, organizationId))) .returning(); return updated || undefined; } async listApiKeys(organizationId: number): Promise { return await db.select().from(organizationApiKeys) .where(eq(organizationApiKeys.organizationId, organizationId)) .orderBy(desc(organizationApiKeys.createdAt)); } async deleteApiKey(id: number, organizationId: number): Promise { await db.delete(organizationApiKeys) .where(and(eq(organizationApiKeys.id, id), eq(organizationApiKeys.organizationId, organizationId))); } async getApiKeyByHash(rawKey: string): Promise { // 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 { 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 { await db.update(organizationApiKeys).set({ lastUsedAt: new Date() }).where(eq(organizationApiKeys.id, id)); } // ── Custom JS Pages ────────────────────────────────────────────────── async getCustomPages(organizationId: number): Promise { return db.select().from(customPages) .where(eq(customPages.organizationId, organizationId)) .orderBy(asc(customPages.name)); } async getCustomPage(id: number, organizationId: number): Promise { 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 { 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 { const [row] = await db.insert(customPages).values(page).returning(); return row; } async updateCustomPage(id: number, organizationId: number, updates: Partial): Promise { 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 { await db.delete(customPages) .where(and(eq(customPages.id, id), eq(customPages.organizationId, organizationId))); } // ── Automations ────────────────────────────────────────────────────────── async getAutomations(organizationId: number): Promise { return db.select().from(automations) .where(eq(automations.organizationId, organizationId)) .orderBy(asc(automations.name)); } async getOfflineAutomations(organizationId: number): Promise { 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 { 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 { const [row] = await db.insert(automations).values(automation).returning(); return row; } async updateAutomation(id: number, organizationId: number, updates: Partial): Promise { 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 { await db.delete(automations) .where(and(eq(automations.id, id), eq(automations.organizationId, organizationId))); } // Все активные schedule-автоматизации всех организаций (для планировщика, вызывать в withSuperAdmin) async getActiveScheduleAutomations(): Promise { return db.select().from(automations) .where(and(eq(automations.trigger, 'schedule'), eq(automations.isActive, true))) .orderBy(asc(automations.id)); } async markAutomationScheduledRun(id: number): Promise { await db.update(automations) .set({ lastScheduledRunAt: new Date() }) .where(eq(automations.id, id)); } }