723 lines
27 KiB
TypeScript
723 lines
27 KiB
TypeScript
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<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));
|
||
}
|
||
|
||
async updateUserActiveChat(
|
||
userId: number,
|
||
organizationId: number,
|
||
type: 'messenger' | 'task',
|
||
id: number,
|
||
visible: boolean
|
||
): Promise<void> {
|
||
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<void> {
|
||
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<void> {
|
||
await db.delete(webPushSubscriptions).where(eq(webPushSubscriptions.endpoint, endpoint));
|
||
await db.insert(webPushSubscriptions).values({ userId, organizationId, endpoint, subscription });
|
||
|
||
// Keep at most 3 subscriptions per user to prevent stale devices from
|
||
// accumulating and receiving unnecessary sync pushes.
|
||
const MAX_SUBSCRIPTIONS_PER_USER = 3;
|
||
const userSubs = await db
|
||
.select({ id: webPushSubscriptions.id })
|
||
.from(webPushSubscriptions)
|
||
.where(and(
|
||
eq(webPushSubscriptions.userId, userId),
|
||
eq(webPushSubscriptions.organizationId, organizationId)
|
||
))
|
||
.orderBy(webPushSubscriptions.id);
|
||
|
||
if (userSubs.length > MAX_SUBSCRIPTIONS_PER_USER) {
|
||
const toDelete = userSubs.slice(0, userSubs.length - MAX_SUBSCRIPTIONS_PER_USER).map(s => s.id);
|
||
await db.delete(webPushSubscriptions).where(inArray(webPushSubscriptions.id, toDelete));
|
||
}
|
||
}
|
||
|
||
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, 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<OrganizationApiKey | undefined> {
|
||
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<void> {
|
||
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<void> {
|
||
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<OrganizationApiKey | undefined> {
|
||
const updates: Partial<Pick<OrganizationApiKey, 'label' | 'scopes'>> = {};
|
||
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<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)));
|
||
}
|
||
|
||
// Все активные schedule-автоматизации всех организаций (для планировщика, вызывать в withSuperAdmin)
|
||
async getActiveScheduleAutomations(): Promise<Automation[]> {
|
||
return db.select().from(automations)
|
||
.where(and(eq(automations.trigger, 'schedule'), eq(automations.isActive, true)))
|
||
.orderBy(asc(automations.id));
|
||
}
|
||
|
||
async markAutomationScheduledRun(id: number): Promise<void> {
|
||
await db.update(automations)
|
||
.set({ lastScheduledRunAt: new Date() })
|
||
.where(eq(automations.id, id));
|
||
}
|
||
|
||
}
|