import { sql, relations, getTableColumns } from "drizzle-orm"; import { pgTable, text, varchar, integer, boolean, timestamp, jsonb, serial, bigserial, doublePrecision, unique, foreignKey, uniqueIndex, index, numeric, real, date, uuid, type AnyPgColumn, customType } from "drizzle-orm/pg-core"; import { createInsertSchema } from "drizzle-zod"; import { z } from "zod"; // pgvector custom type (Drizzle не поддерживает vector нативно) const vector = (dimensions: number) => customType<{ data: number[]; driverData: string }>({ dataType() { return `vector(${dimensions})`; }, toDriver(value: number[]): string { return `[${value.join(",")}]`; }, fromDriver(value: string): number[] { return value.slice(1, -1).split(",").map(Number); }, }); // Super Admins table (system-level, not tied to any organization) export const superAdmins = pgTable("super_admins", { id: serial("id").primaryKey(), email: varchar("email", { length: 255 }).notNull().unique("super_admins_email_key"), passwordHash: varchar("password_hash", { length: 255 }).notNull(), name: varchar("name", { length: 200 }).notNull(), createdAt: timestamp("created_at").defaultNow(), lastLogin: timestamp("last_login"), }); export type SuperAdmin = typeof superAdmins.$inferSelect; export type InsertSuperAdmin = typeof superAdmins.$inferInsert; export const insertSuperAdminSchema = createInsertSchema(superAdmins).omit({ id: true, createdAt: true, lastLogin: true }); // Organizations table export const organizations = pgTable("organizations", { id: serial("id").primaryKey(), name: varchar("name", { length: 100 }).notNull().unique(), slug: varchar("slug", { length: 50 }).notNull().unique(), displayName: varchar("display_name", { length: 200 }).notNull(), logoUrl: varchar("logo_url", { length: 500 }), themeColor: varchar("theme_color", { length: 7 }).default("#1976d2"), activeSkinId: varchar("active_skin_id", { length: 50 }).default("iistwin"), createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), isActive: boolean("is_active").default(true), billingBlocked: boolean("billing_blocked").default(false), medscheduleEnabled: boolean("medschedule_enabled").notNull().default(false), }); // Organization skins export const organizationSkins = pgTable("organization_skins", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), skinId: varchar("skin_id", { length: 50 }).notNull(), name: varchar("name", { length: 100 }).notNull(), description: text("description"), tokens: jsonb("tokens").notNull(), isEditable: boolean("is_editable").default(true), isActive: boolean("is_active").default(false), createdBy: integer("created_by").notNull().references(() => users.id, { onDelete: "cascade" }), createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), }, (table) => ({ uniqueOrgSkin: unique().on(table.organizationId, table.skinId), })); export type OrganizationSkin = typeof organizationSkins.$inferSelect; export type InsertOrganizationSkin = typeof organizationSkins.$inferInsert; // Billing settings per organization (1:1) export const organizationBilling = pgTable("organization_billing", { organizationId: integer("organization_id").primaryKey().references(() => organizations.id, { onDelete: "cascade" }), pricePerUser: numeric("price_per_user", { precision: 10, scale: 2 }).notNull().default("0"), balance: numeric("balance", { precision: 10, scale: 2 }).notNull().default("0"), currency: varchar("currency", { length: 10 }).notNull().default("RUB"), nextBillingDate: date("next_billing_date"), blockedAt: timestamp("blocked_at"), updatedAt: timestamp("updated_at").defaultNow(), }); export type OrganizationBilling = typeof organizationBilling.$inferSelect; export type InsertOrganizationBilling = typeof organizationBilling.$inferInsert; // Billing transactions (credits and debits) export const billingTransactions = pgTable("billing_transactions", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), amount: numeric("amount", { precision: 10, scale: 2 }).notNull(), type: varchar("type", { length: 10 }).notNull(), // 'credit' | 'debit' description: text("description"), createdAt: timestamp("created_at").defaultNow(), createdBy: integer("created_by"), // nullable super admin ID }); export type BillingTransaction = typeof billingTransactions.$inferSelect; export type InsertBillingTransaction = typeof billingTransactions.$inferInsert; // Billing payments (self-service top-ups via payment provider) export const billingPayments = pgTable("billing_payments", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), provider: varchar("provider", { length: 30 }).notNull(), externalId: varchar("external_id", { length: 255 }).notNull().unique("billing_payments_external_id_key"), amount: numeric("amount", { precision: 10, scale: 2 }).notNull(), currency: varchar("currency", { length: 10 }).notNull().default("RUB"), status: varchar("status", { length: 20 }).notNull().default("pending"), // 'pending' | 'succeeded' | 'canceled' idempotencyKey: varchar("idempotency_key", { length: 255 }).notNull().unique("billing_payments_idempotency_key_key"), metadata: jsonb("metadata"), createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), }, (table) => ({ orgStatusIdx: index("billing_payments_org_status_idx").on(table.organizationId, table.status), })); export type BillingPayment = typeof billingPayments.$inferSelect; export type InsertBillingPayment = typeof billingPayments.$inferInsert; // Users table export const users = pgTable("users", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), email: varchar("email", { length: 255 }).notNull(), passwordHash: varchar("password_hash", { length: 255 }).notNull(), firstName: varchar("first_name", { length: 100 }).notNull(), lastName: varchar("last_name", { length: 100 }).notNull(), middleName: varchar("middle_name", { length: 100 }), position: varchar("position", { length: 200 }), phone: varchar("phone", { length: 50 }), additionalPhones: jsonb("additional_phones").default(sql`'[]'`), additionalEmails: jsonb("additional_emails").default(sql`'[]'`), appRole: varchar("app_role", { length: 100 }).notNull().default("user"), // 'admin', 'user', 'accountant' // Флаги доступа к модулям (матрица на /users): GPS и Финансы gpsAccess: boolean("gps_access").notNull().default(true), financeAccess: boolean("finance_access").notNull().default(true), isActive: boolean("is_active").default(true), emailVerified: boolean("email_verified").default(false), verificationToken: varchar("verification_token", { length: 255 }), resetPasswordToken: varchar("reset_password_token", { length: 255 }), resetPasswordTokenHash: varchar("reset_password_token_hash", { length: 64 }), resetPasswordExpires: timestamp("reset_password_expires"), lastLogin: timestamp("last_login"), lastActivityAt: timestamp("last_activity_at"), statusId: integer("status_id"), createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), }, (table) => ({ // Быстрый поиск пользователей организации по роли orgRoleIdx: index("users_org_role_idx").on(table.organizationId, table.appRole), })); // Колонки users без чувствительных данных (хэш пароля, токены подтверждения и сброса). // Использовать во всех массовых/клиентских выборках пользователей. const { passwordHash: _ph, verificationToken: _vt, resetPasswordToken: _rpt, resetPasswordTokenHash: _rpth, resetPasswordExpires: _rpe, ...restSafeUserColumns } = getTableColumns(users); export const safeUserColumns = restSafeUserColumns; export const userStatuses = pgTable("user_statuses", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), name: varchar("name", { length: 100 }).notNull(), color: varchar("color", { length: 7 }).default("#e5e7eb"), isDefault: boolean("is_default").default(false), position: integer("position").default(0), createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ orgIdx: index("user_statuses_org_idx").on(table.organizationId), })); export type UserStatus = typeof userStatuses.$inferSelect; export type InsertUserStatus = typeof userStatuses.$inferInsert; // Замещение пользователей (delegation): кто кого замещает на период. // formId IS NULL — заместитель по умолчанию для всех форм организации. export const userDelegations = pgTable("user_delegations", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), // кто отсутствует delegateUserId: integer("delegate_user_id").notNull().references(() => users.id, { onDelete: "cascade" }), // заместитель formId: integer("form_id").references(() => forms.id, { onDelete: "cascade" }), // NULL = по умолчанию dateFrom: date("date_from", { mode: "string" }).notNull(), dateTo: date("date_to", { mode: "string" }).notNull(), isActive: boolean("is_active").notNull().default(true), createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }), createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ userActiveIdx: index("user_delegations_user_active_idx").on(table.userId, table.isActive), orgIdx: index("user_delegations_org_idx").on(table.organizationId), })); export type UserDelegation = typeof userDelegations.$inferSelect; export type InsertUserDelegation = typeof userDelegations.$inferInsert; // User sessions table export const userSessions = pgTable("user_sessions", { id: serial("id").primaryKey(), userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), // Шаг 0.6: plain refresh-токены в БД не хранятся — только sha256-хэши. // refresh_token/parent_refresh_token/replaced_by_token — legacy-колонки (не пишутся, удаление отложено). refreshToken: varchar("refresh_token", { length: 500 }).unique(), refreshTokenHash: varchar("refresh_token_hash", { length: 64 }).notNull().unique("user_sessions_refresh_token_hash_key"), familyId: uuid("family_id").notNull(), familyCreatedAt: timestamp("family_created_at").notNull(), parentRefreshToken: varchar("parent_refresh_token", { length: 500 }), parentRefreshTokenHash: varchar("parent_refresh_token_hash", { length: 64 }), isReplaced: boolean("is_replaced").notNull().default(false), replacedByToken: varchar("replaced_by_token", { length: 500 }), replacedByTokenHash: varchar("replaced_by_token_hash", { length: 64 }), isRevoked: boolean("is_revoked").notNull().default(false), expiresAt: timestamp("expires_at").notNull(), remember: boolean("remember").notNull().default(false), createdAt: timestamp("created_at").defaultNow(), ipAddress: varchar("ip_address", { length: 45 }), userAgent: text("user_agent"), }, (table) => ({ userIdIdx: index("user_sessions_user_id_idx").on(table.userId), familyIdIdx: index("user_sessions_family_id_idx").on(table.familyId), parentRefreshTokenIdx: index("user_sessions_parent_refresh_token_idx").on(table.parentRefreshToken), parentRefreshTokenHashIdx: index("user_sessions_parent_refresh_token_hash_idx").on(table.parentRefreshTokenHash), })); // Invitations table - for inviting users to organization export const invitations = pgTable("invitations", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), email: varchar("email", { length: 255 }).notNull(), token: varchar("token", { length: 64 }).notNull().unique("invitations_token_key"), createdBy: integer("created_by").notNull().references(() => users.id, { onDelete: "cascade" }), createdAt: timestamp("created_at").defaultNow(), expiresAt: timestamp("expires_at").notNull(), usedAt: timestamp("used_at"), }); export const invitationsRelations = relations(invitations, ({ one }) => ({ organization: one(organizations, { fields: [invitations.organizationId], references: [organizations.id], }), createdByUser: one(users, { fields: [invitations.createdBy], references: [users.id], }), })); // User custom fields definitions export const userCustomFields = pgTable("user_custom_fields", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), name: varchar("name", { length: 200 }).notNull(), code: varchar("code", { length: 100 }).notNull(), type: varchar("type", { length: 50 }).notNull(), // text, number, date, select, user, checkbox options: jsonb("options"), // For select type: array of options isRequired: boolean("is_required").default(false), position: integer("position").default(0), createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), }); // User custom field values export const userCustomValues = pgTable("user_custom_values", { id: serial("id").primaryKey(), userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), fieldId: integer("field_id").notNull().references(() => userCustomFields.id, { onDelete: "cascade" }), value: text("value"), createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), }); export const userCustomFieldsRelations = relations(userCustomFields, ({ one, many }) => ({ organization: one(organizations, { fields: [userCustomFields.organizationId], references: [organizations.id], }), values: many(userCustomValues), })); export const userCustomValuesRelations = relations(userCustomValues, ({ one }) => ({ user: one(users, { fields: [userCustomValues.userId], references: [users.id], }), field: one(userCustomFields, { fields: [userCustomValues.fieldId], references: [userCustomFields.id], }), })); // User profile tabs table (analogous to formTabs for users) export const userProfileTabs = pgTable("user_profile_tabs", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), name: varchar("name", { length: 200 }).notNull(), code: varchar("code", { length: 100 }).notNull(), position: integer("position").notNull().default(0), displayOrder: integer("display_order").default(0), createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ uniqueTabCode: unique().on(table.organizationId, table.code), })); // User profile fields table (analogous to formFields for users) export const userProfileFields = pgTable("user_profile_fields", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), tabId: integer("tab_id").references(() => userProfileTabs.id, { onDelete: "set null" }), name: varchar("name", { length: 200 }).notNull(), code: varchar("code", { length: 100 }).notNull(), type: varchar("type", { length: 50 }).notNull(), isRequired: boolean("is_required").default(false), placeholder: text("placeholder"), defaultValue: text("default_value"), validationRules: jsonb("validation_rules"), options: jsonb("options"), position: integer("position").notNull().default(0), displayOrder: integer("display_order").default(0), linkedFormId: integer("linked_form_id").references(() => forms.id, { onDelete: "set null" }), companyAutofill: jsonb("company_autofill"), maxFileCount: integer("max_file_count"), maxFileSizeMB: integer("max_file_size_mb"), autoProlongation: boolean("auto_prolongation").default(false), autoProlongationLabel: text("auto_prolongation_label"), createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ uniqueFieldCode: unique().on(table.organizationId, table.code), })); // User profile field values table (analogous to taskFieldValues for users) export const userProfileFieldValues = pgTable("user_profile_field_values", { id: serial("id").primaryKey(), userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), fieldId: integer("field_id").notNull().references(() => userProfileFields.id), value: jsonb("value"), createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), }, (table) => ({ uniqueUserField: unique().on(table.userId, table.fieldId), userFieldIdx: index("user_profile_field_values_user_field_idx").on(table.userId, table.fieldId), })); export const userProfileTabsRelations = relations(userProfileTabs, ({ one, many }) => ({ organization: one(organizations, { fields: [userProfileTabs.organizationId], references: [organizations.id], }), fields: many(userProfileFields), })); export const userProfileFieldsRelations = relations(userProfileFields, ({ one, many }) => ({ organization: one(organizations, { fields: [userProfileFields.organizationId], references: [organizations.id], }), tab: one(userProfileTabs, { fields: [userProfileFields.tabId], references: [userProfileTabs.id], }), values: many(userProfileFieldValues), })); export const userProfileFieldValuesRelations = relations(userProfileFieldValues, ({ one }) => ({ user: one(users, { fields: [userProfileFieldValues.userId], references: [users.id], }), field: one(userProfileFields, { fields: [userProfileFieldValues.fieldId], references: [userProfileFields.id], }), })); // User field history table (numeric history of user profile fields with type history-number) export const userFieldHistory = pgTable("user_field_history", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), fieldId: integer("field_id").notNull().references(() => userProfileFields.id, { onDelete: "cascade" }), value: numeric("value").notNull(), taskId: integer("task_id").references(() => tasks.id, { onDelete: "set null" }), // задача, из которой выставлена оценка (может быть null) createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ userFieldCreatedIdx: index("user_field_history_user_field_created_idx").on(table.userId, table.fieldId, table.createdAt), orgIdx: index("user_field_history_org_idx").on(table.organizationId), })); export const userFieldHistoryRelations = relations(userFieldHistory, ({ one }) => ({ organization: one(organizations, { fields: [userFieldHistory.organizationId], references: [organizations.id], }), user: one(users, { fields: [userFieldHistory.userId], references: [users.id], }), field: one(userProfileFields, { fields: [userFieldHistory.fieldId], references: [userProfileFields.id], }), task: one(tasks, { fields: [userFieldHistory.taskId], references: [tasks.id], }), })); // User profile audit log table (history of user profile changes) export const userProfileAuditLog = pgTable("user_profile_audit_log", { id: serial("id").primaryKey(), userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), action: varchar("action", { length: 50 }).notNull(), // 'user.created', 'user.updated', 'user.activated', 'user.deactivated', 'user.deleted', 'password.changed', 'invitation.created', 'user.role.assigned', 'user.role.removed', 'field.changed' fieldId: integer("field_id").references(() => userProfileFields.id), fieldName: text("field_name"), oldValue: jsonb("old_value"), newValue: jsonb("new_value"), changedBy: integer("changed_by").references(() => users.id), changedByName: text("changed_by_name"), metadata: jsonb("metadata"), createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ userCreatedIdx: index("user_profile_audit_log_user_created_idx").on(table.userId, table.createdAt.desc()), })); export const userProfileAuditLogRelations = relations(userProfileAuditLog, ({ one }) => ({ user: one(users, { fields: [userProfileAuditLog.userId], references: [users.id], }), field: one(userProfileFields, { fields: [userProfileAuditLog.fieldId], references: [userProfileFields.id], }), changedByUser: one(users, { fields: [userProfileAuditLog.changedBy], references: [users.id], }), })); // Forms table export const forms = pgTable("forms", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), name: varchar("name", { length: 200 }).notNull(), description: text("description"), createdBy: integer("created_by").notNull().references(() => users.id), createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), isActive: boolean("is_active").default(true), chatLayout: varchar("chat_layout", { length: 20 }).default("default"), // 'default', 'left', 'tab' chatEnabled: boolean("chat_enabled").default(true), // включить/отключить чат для формы qrGenerationEnabled: boolean("qr_generation_enabled").default(false), // показывать кнопку генерации QR в реестре форм systemFieldsTabId: integer("system_fields_tab_id"), // ID вкладки для системных полей defaultTabId: integer("default_tab_id"), // ID вкладки по умолчанию detailLayoutCode: text("detail_layout_code"), // JS-код кастомного оформления страницы задачи titleTemplate: text("title_template"), // шаблон заголовка задачи: "{{field_code}} - {{other_field}}" hiddenSystemFields: text("hidden_system_fields").array().default(sql`'{}'::text[]`), // скрытые системные поля: ['createdAt','createdBy'] aiSummary: text("ai_summary"), // авто-сгенерированное GPT-резюме формы для RAG-памяти aiSummaryIsAuto: boolean("ai_summary_is_auto").default(true), // true = сгенерировано автоматически, false = отредактировано вручную systemTabOrder: text("system_tab_order").array(), // порядок системных вкладок: ['subtasks', 'related', 'chat', 'history', 'access'] visibility: varchar("visibility", { length: 20 }).default("restricted"), // 'organization' = все видят, 'restricted' = только из таблицы правил offlineCache: jsonb("offline_cache").$type<{ enabled: boolean; maxTasks?: number; strategy?: 'recent' | 'assigned' | 'all'; includeFields?: string[]; }>(), // настройки офлайн-кэша формы (null = не кэшировать) folderId: integer("folder_id"), }, (table) => ({ aiSummaryIdx: index("forms_ai_summary_idx").on(table.aiSummary), folderIdx: index("forms_folder_idx").on(table.folderId), })); // Form Access Rules (права доступа к форме и её задачам) export const formAccessRules = pgTable("form_access_rules", { id: serial("id").primaryKey(), formId: integer("form_id").notNull().references(() => forms.id, { onDelete: "cascade" }), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), targetType: varchar("target_type", { length: 10 }).notNull(), // 'user' | 'role' targetId: integer("target_id").notNull(), // user_id или role_id accessLevel: varchar("access_level", { length: 20 }).notNull().default("participate"), // 'participate' | 'view_all' | 'admin' createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ uniqueFormTarget: unique("form_access_rules_unique").on(table.formId, table.targetType, table.targetId), formIdx: index("form_access_rules_form_idx").on(table.formId), orgIdx: index("form_access_rules_org_idx").on(table.organizationId), })); export type FormAccessRule = typeof formAccessRules.$inferSelect; export type InsertFormAccessRule = typeof formAccessRules.$inferInsert; // User offline subscriptions (какие формы пользователь хочет иметь доступными офлайн) export const userOfflineSubscriptions = pgTable("user_offline_subscriptions", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), formId: integer("form_id").notNull().references(() => forms.id, { onDelete: "cascade" }), isActive: boolean("is_active").default(true), includeRelatedTasks: boolean("include_related_tasks").default(true), // кэшировать связанные задачи includeFiles: boolean("include_files").default(false), // кэшировать файлы задач createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), }, (table) => ({ uniqueUserForm: unique("user_offline_subscriptions_unique").on(table.userId, table.formId), userIdx: index("user_offline_subscriptions_user_idx").on(table.userId), formIdx: index("user_offline_subscriptions_form_idx").on(table.formId), orgIdx: index("user_offline_subscriptions_org_idx").on(table.organizationId), })); export type UserOfflineSubscription = typeof userOfflineSubscriptions.$inferSelect; export type InsertUserOfflineSubscription = typeof userOfflineSubscriptions.$inferInsert; // Form folders table export const formFolders = pgTable("form_folders", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), name: varchar("name", { length: 200 }).notNull(), parentId: integer("parent_id"), position: integer("position").notNull().default(0), createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), }, (table) => ({ orgParentIdx: index("form_folders_org_parent_idx").on(table.organizationId, table.parentId), positionIdx: index("form_folders_position_idx").on(table.position), })); export type FormFolder = typeof formFolders.$inferSelect; export type InsertFormFolder = typeof formFolders.$inferInsert; // Form tabs table export const formTabs = pgTable("form_tabs", { id: serial("id").primaryKey(), formId: integer("form_id").notNull().references(() => forms.id, { onDelete: "cascade" }), name: varchar("name", { length: 200 }).notNull(), code: varchar("code", { length: 100 }).notNull(), type: varchar("type", { length: 20 }).notNull().default("fields"), // 'fields', 'excel', or 'table' position: integer("position").notNull().default(0), displayOrder: integer("display_order").default(0), // визуальный порядок вкладок (DnD) tableColumns: jsonb("table_columns"), // для type='table': [{id, name, type, width}] createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ uniqueTabCode: unique().on(table.formId, table.code), })); // Form fields table export const formFields = pgTable("form_fields", { id: serial("id").primaryKey(), formId: integer("form_id").notNull().references(() => forms.id, { onDelete: "cascade" }), tabId: integer("tab_id").references(() => formTabs.id, { onDelete: "set null" }), // вкладка для поля name: varchar("name", { length: 200 }).notNull(), code: varchar("code", { length: 100 }).notNull(), type: varchar("type", { length: 50 }).notNull(), // text, number, date, select, checkbox, toggle, button, user, file, textarea, rich-text, datetime, history-number, table, task, document_template isRequired: boolean("is_required").default(false), placeholder: text("placeholder"), defaultValue: text("default_value"), validationRules: jsonb("validation_rules"), // правила валидации в формате JSON requiredRules: jsonb("required_rules"), // условная обязательность поля immutableRules: jsonb("immutable_rules"), // условная неизменность поля (read-only) visibilityRules: jsonb("visibility_rules"), // условия видимости поля options: jsonb("options"), // опции для select полей position: integer("position").notNull().default(0), // порядок создания (стабильный) displayOrder: integer("display_order").default(0), // визуальный порядок (DnD) linkedFormId: integer("linked_form_id").references(() => forms.id, { onDelete: "set null" }), // связанная форма для типа task buttonActionType: varchar("button_action_type", { length: 20 }), // 'open_url', 'create_task' - для типа button buttonUrl: text("button_url"), // URL для открытия (если buttonActionType = 'open_url') buttonFormId: integer("button_form_id").references(() => forms.id, { onDelete: "set null" }), // форма для создания задачи (если buttonActionType = 'create_task') companyAutofill: jsonb("company_autofill"), // настройки автозаполнения для типа company: { fullName: 'field_code', inn: 'field_code', ... } taskRelationType: varchar("task_relation_type", { length: 10 }), // null | 'parent' | 'child' — характер связи для поля типа task maxFileCount: integer("max_file_count"), // максимальное количество файлов для поля типа file (null = без ограничений) maxFileSizeMB: integer("max_file_size_mb"), // максимальный суммарный размер файлов в МБ для поля типа file (null = без ограничений) autoProlongation: boolean("auto_prolongation").default(false), // включено ли альтернативное значение (спецзначение) для date/datetime autoProlongationLabel: text("auto_prolongation_label"), // кастомный текст спецзначения (null = "Автопролонгация") linkToUserProfile: boolean("link_to_user_profile").default(false), // связывать задачи формы с профилем пользователя (поле типа user) allowMultiple: boolean("allow_multiple").default(false), // разрешить множественный выбор для select/table ganttRole: varchar("gantt_role", { length: 20 }), // 'start' | 'end' | 'milestone' | null — роль поля в Гантте deletedAt: timestamp("deleted_at"), // мягкое удаление: скрыто везде, значения в задачах сохранены createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ // Уникальность кода поля внутри формы — только среди активных (удалённые не мешают) uniqueFieldCode: uniqueIndex("form_fields_form_id_code_active_key").on(table.formId, table.code).where(sql`${table.deletedAt} IS NULL`), // Композитный уникальный ключ для referential integrity fieldFormComposite: unique().on(table.id, table.formId), })); // Form statuses table export const formStatuses = pgTable("form_statuses", { id: serial("id").primaryKey(), formId: integer("form_id").notNull().references(() => forms.id, { onDelete: "cascade" }), name: varchar("name", { length: 100 }).notNull(), color: varchar("color", { length: 7 }).notNull().default("#e5e7eb"), position: integer("position").notNull().default(0), isInitial: boolean("is_initial").default(false), // начальный статус isFinal: boolean("is_final").default(false), // финальный статус entryConditions: jsonb("entry_conditions"), // условия автоматического входа в статус createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ // Композитный уникальный ключ для referential integrity statusFormComposite: unique().on(table.id, table.formId), // Только один initial статус на форму oneInitialPerForm: uniqueIndex("one_initial_status_per_form").on(table.formId).where(sql`${table.isInitial} = true`), })); // Status transitions table export const statusTransitions = pgTable("status_transitions", { id: serial("id").primaryKey(), formId: integer("form_id").notNull().references(() => forms.id, { onDelete: "cascade" }), fromStatusId: integer("from_status_id").notNull().references(() => formStatuses.id), toStatusId: integer("to_status_id").notNull().references(() => formStatuses.id), // статус при одобрении rejectToStatusId: integer("reject_to_status_id").references(() => formStatuses.id), // статус при отклонении (опционально) actionType: varchar("action_type", { length: 20 }).notNull().default("approve"), // 'approve', 'reject', 'custom' - для совместимости actionName: varchar("action_name", { length: 100 }), // название кнопки действия conditions: jsonb("conditions"), // условия для перехода requiredRole: varchar("required_role", { length: 20 }), // роль, необходимая для действия assigneeUserId: integer("assignee_user_id").references(() => users.id), // фиксированный согласующий assigneeConditions: jsonb("assignee_conditions"), // условия для динамического назначения согласующего approveButtonText: varchar("approve_button_text", { length: 50 }).default("Одобрить"), approveButtonColor: varchar("approve_button_color", { length: 7 }).default("#22c55e"), // зеленый rejectButtonText: varchar("reject_button_text", { length: 50 }).default("Отклонить"), rejectButtonColor: varchar("reject_button_color", { length: 7 }).default("#ef4444"), // красный createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ // Композитные foreign keys: статусы должны принадлежать одной форме fromStatusSameForm: foreignKey({ columns: [table.fromStatusId, table.formId], foreignColumns: [formStatuses.id, formStatuses.formId], name: "fk_transition_from_status_same_form" }), toStatusSameForm: foreignKey({ columns: [table.toStatusId, table.formId], foreignColumns: [formStatuses.id, formStatuses.formId], name: "fk_transition_to_status_same_form" }), })); // Tasks table export const tasks = pgTable("tasks", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), // денормализовано для производительности индексов formId: integer("form_id").notNull().references(() => forms.id), title: varchar("title", { length: 500 }).notNull(), description: text("description"), currentStatusId: integer("current_status_id").notNull().references(() => formStatuses.id), assignedTo: integer("assigned_to").references(() => users.id), // ответственный createdBy: integer("created_by").notNull().references(() => users.id), createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), completedAt: timestamp("completed_at"), isCompleted: boolean("is_completed").default(false), // флаг завершенности задачи dueDate: timestamp("due_date"), // дедлайн // Поля для подзадач parentTaskId: integer("parent_task_id"), // ссылка на родительскую задачу (null для корневых) depth: integer("depth").default(0), // глубина вложенности (0 для корневых) position: integer("position").default(0), // позиция среди siblings }, (table) => ({ // Композитный уникальный ключ для referential integrity taskFormComposite: unique().on(table.id, table.formId), // Композитный foreign key: статус должен принадлежать той же форме statusSameForm: foreignKey({ columns: [table.currentStatusId, table.formId], foreignColumns: [formStatuses.id, formStatuses.formId], name: "fk_task_status_same_form" }), // Составные индексы по organization_id для tenant-уровневых выборок orgFormIdx: index("tasks_org_id_form_id_idx").on(table.organizationId, table.formId), orgStatusIdx: index("tasks_org_id_status_idx").on(table.organizationId, table.currentStatusId), orgAssignedIdx: index("tasks_org_id_assigned_idx").on(table.organizationId, table.assignedTo), // Индексы для подзадач и дедлайнов parentTaskIdx: index("tasks_parent_task_id_idx").on(table.parentTaskId), dueDateIdx: index("tasks_due_date_idx").on(table.dueDate), })); // Task relations table (many-to-many via form fields) export const taskRelations = pgTable("task_relations", { id: serial("id").primaryKey(), parentTaskId: integer("parent_task_id").notNull().references(() => tasks.id, { onDelete: "cascade" }), childTaskId: integer("child_task_id").notNull().references(() => tasks.id, { onDelete: "cascade" }), fieldId: integer("field_id").references(() => formFields.id, { onDelete: "set null" }), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ // Уникальная пара связанных задач: имя совпадает с существующим индексом в БД uniqueRelation: unique("task_relations_parent_task_id_child_task_id_field_id_key").on(table.parentTaskId, table.childTaskId, table.fieldId), })); export const insertTaskRelationSchema = createInsertSchema(taskRelations).omit({ id: true, createdAt: true, }); export type TaskRelation = typeof taskRelations.$inferSelect; export type InsertTaskRelation = z.infer; // Task field values table export const taskFieldValues = pgTable("task_field_values", { id: serial("id").primaryKey(), taskId: integer("task_id").notNull().references(() => tasks.id, { onDelete: "cascade" }), fieldId: integer("field_id").notNull().references(() => formFields.id), formId: integer("form_id").notNull().references(() => forms.id), // Для referential integrity value: jsonb("value"), // значение поля в JSON формате createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), }, (table) => ({ // Уникальное значение поля на задачу uniqueTaskField: unique().on(table.taskId, table.fieldId), // Явный индекс для быстрой выборки значений задачи (требование задачи #118) taskFieldIdx: index("task_field_values_task_field_idx").on(table.taskId, table.fieldId), // Композитные foreign keys: все должны принадлежать одной форме taskSameForm: foreignKey({ columns: [table.taskId, table.formId], foreignColumns: [tasks.id, tasks.formId], name: "fk_task_field_value_task_same_form" }), fieldSameForm: foreignKey({ columns: [table.fieldId, table.formId], foreignColumns: [formFields.id, formFields.formId], name: "fk_task_field_value_field_same_form" }), })); // Field history table export const fieldHistory = pgTable("field_history", { id: serial("id").primaryKey(), taskId: integer("task_id").notNull().references(() => tasks.id, { onDelete: "cascade" }), fieldId: integer("field_id").notNull().references(() => formFields.id), formId: integer("form_id").notNull().references(() => forms.id, { onDelete: "cascade" }), oldValue: jsonb("old_value"), newValue: jsonb("new_value"), changedBy: integer("changed_by").notNull().references(() => users.id), changedAt: timestamp("changed_at").defaultNow(), }, (table) => ({ // Композитные foreign keys: все должны принадлежать одной форме taskSameForm: foreignKey({ columns: [table.taskId, table.formId], foreignColumns: [tasks.id, tasks.formId], name: "fk_field_history_task_same_form" }), fieldSameForm: foreignKey({ columns: [table.fieldId, table.formId], foreignColumns: [formFields.id, formFields.formId], name: "fk_field_history_field_same_form" }), })); // Task messages table (for chat functionality) export const taskMessages = pgTable("task_messages", { id: serial("id").primaryKey(), taskId: integer("task_id").notNull().references(() => tasks.id, { onDelete: "cascade" }), formId: integer("form_id").notNull().references(() => forms.id, { onDelete: "cascade" }), authorId: integer("author_id").references(() => users.id), // null если от бота botId: integer("bot_id"), // ID бота-автора (FK добавится после создания таблицы bots) replyToMessageId: integer("reply_to_message_id").references((): AnyPgColumn => taskMessages.id, { onDelete: "cascade" }), // ссылка на сообщение, на которое отвечают message: text("message").notNull().default(""), messageType: varchar("message_type", { length: 20 }).notNull().default("comment"), // 'comment', 'system', 'status_change', 'bot', 'reminder' mentionedUserIds: jsonb("mentioned_user_ids"), // массив ID упомянутых пользователей botButtons: jsonb("bot_buttons"), // кнопки для бот-сообщений [{text, callbackData, style, row}] visibleToUserIds: jsonb("visible_to_user_ids").$type(), // NULL = видят все участники чата; массив = только перечисленные (+ автор) attachments: jsonb("attachments").$type>(), // Идемпотентность: клиентский ключ сообщения (uuid от клиента). Защищает от // дублей при повторной отправке (multi-tab replay офлайн-очереди, ретраи). // Partial unique index — см. миграцию 0086. clientMessageId: varchar("client_message_id", { length: 64 }), createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ // Композитный foreign key: задача должна принадлежать той же форме taskSameForm: foreignKey({ columns: [table.taskId, table.formId], foreignColumns: [tasks.id, tasks.formId], name: "fk_task_message_task_same_form" }), // Индекс для быстрой загрузки ленты чата задачи (убывающий createdAt для сортировки новых первыми) taskCreatedIdx: index("task_messages_task_created_idx").on(table.taskId, table.createdAt.desc()), })); // User notifications table export const userNotifications = pgTable("user_notifications", { id: serial("id").primaryKey(), userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), type: varchar("type", { length: 50 }).notNull(), // 'task_mention', 'task_assigned', 'status_change', 'task_reply' title: varchar("title", { length: 200 }).notNull(), message: text("message").notNull(), taskId: integer("task_id").references(() => tasks.id, { onDelete: "cascade" }), messageId: integer("message_id").references(() => taskMessages.id, { onDelete: "cascade" }), isRead: boolean("is_read").default(false), createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ // Индекс для быстрого получения уведомлений пользователя userNotificationsIndex: index("user_notifications_idx").on(table.userId, table.organizationId, table.isRead, table.createdAt), })); // Message reads table (for read receipts) export const messageReads = pgTable("message_reads", { id: serial("id").primaryKey(), messageId: integer("message_id").notNull().references(() => taskMessages.id, { onDelete: "cascade" }), userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), readAt: timestamp("read_at").defaultNow(), }, (table) => ({ // Композитный уникальный индекс: один пользователь может прочитать сообщение только один раз messageUserUnique: unique("message_reads_unique").on(table.messageId, table.userId), // Индекс для быстрого поиска прочитанных сообщений по пользователю userOrgIndex: index("message_reads_user_org_idx").on(table.userId, table.organizationId), // Индекс для быстрого поиска читателей сообщения messageOrgIndex: index("message_reads_message_org_idx").on(table.messageId, table.organizationId), })); // Relations export const organizationsRelations = relations(organizations, ({ many }) => ({ users: many(users), forms: many(forms), skins: many(organizationSkins), })); export const organizationSkinsRelations = relations(organizationSkins, ({ one }) => ({ organization: one(organizations, { fields: [organizationSkins.organizationId], references: [organizations.id], }), createdByUser: one(users, { fields: [organizationSkins.createdBy], references: [users.id], }), })); export const usersRelations = relations(users, ({ one, many }) => ({ organization: one(organizations, { fields: [users.organizationId], references: [organizations.id], }), status: one(userStatuses, { fields: [users.statusId], references: [userStatuses.id], }), sessions: many(userSessions), createdForms: many(forms, { relationName: "createdForms" }), createdTasks: many(tasks, { relationName: "createdTasks" }), assignedTasks: many(tasks, { relationName: "assignedTasks" }), fieldChanges: many(fieldHistory), taskMessages: many(taskMessages), notifications: many(userNotifications), })); export const userSessionsRelations = relations(userSessions, ({ one }) => ({ user: one(users, { fields: [userSessions.userId], references: [users.id], }), })); export const userStatusesRelations = relations(userStatuses, ({ one, many }) => ({ organization: one(organizations, { fields: [userStatuses.organizationId], references: [organizations.id], }), users: many(users), })); export const formsRelations = relations(forms, ({ one, many }) => ({ organization: one(organizations, { fields: [forms.organizationId], references: [organizations.id], }), createdBy: one(users, { fields: [forms.createdBy], references: [users.id], }), folder: one(formFolders, { fields: [forms.folderId], references: [formFolders.id], }), fields: many(formFields), tabs: many(formTabs), statuses: many(formStatuses), transitions: many(statusTransitions), tasks: many(tasks), })); export const formFoldersRelations = relations(formFolders, ({ one, many }) => ({ organization: one(organizations, { fields: [formFolders.organizationId], references: [organizations.id], }), parent: one(formFolders, { fields: [formFolders.parentId], references: [formFolders.id], }), children: many(formFolders), forms: many(forms), })); export const formTabsRelations = relations(formTabs, ({ one, many }) => ({ form: one(forms, { fields: [formTabs.formId], references: [forms.id], }), fields: many(formFields), })); export const formFieldsRelations = relations(formFields, ({ one, many }) => ({ form: one(forms, { fields: [formFields.formId], references: [forms.id], }), tab: one(formTabs, { fields: [formFields.tabId], references: [formTabs.id], }), taskValues: many(taskFieldValues), history: many(fieldHistory), })); export const formStatusesRelations = relations(formStatuses, ({ one, many }) => ({ form: one(forms, { fields: [formStatuses.formId], references: [forms.id], }), tasks: many(tasks), fromTransitions: many(statusTransitions, { relationName: "fromTransitions" }), toTransitions: many(statusTransitions, { relationName: "toTransitions" }), })); export const statusTransitionsRelations = relations(statusTransitions, ({ one }) => ({ form: one(forms, { fields: [statusTransitions.formId], references: [forms.id], }), fromStatus: one(formStatuses, { fields: [statusTransitions.fromStatusId], references: [formStatuses.id], }), toStatus: one(formStatuses, { fields: [statusTransitions.toStatusId], references: [formStatuses.id], }), })); export const tasksRelations = relations(tasks, ({ one, many }) => ({ form: one(forms, { fields: [tasks.formId], references: [forms.id], }), currentStatus: one(formStatuses, { fields: [tasks.currentStatusId], references: [formStatuses.id], }), assignedTo: one(users, { fields: [tasks.assignedTo], references: [users.id], }), createdBy: one(users, { fields: [tasks.createdBy], references: [users.id], }), fieldValues: many(taskFieldValues), fieldHistory: many(fieldHistory), messages: many(taskMessages), })); export const taskFieldValuesRelations = relations(taskFieldValues, ({ one }) => ({ task: one(tasks, { fields: [taskFieldValues.taskId], references: [tasks.id], }), field: one(formFields, { fields: [taskFieldValues.fieldId], references: [formFields.id], }), form: one(forms, { fields: [taskFieldValues.formId], references: [forms.id], }), })); export const fieldHistoryRelations = relations(fieldHistory, ({ one }) => ({ task: one(tasks, { fields: [fieldHistory.taskId], references: [tasks.id], }), field: one(formFields, { fields: [fieldHistory.fieldId], references: [formFields.id], }), form: one(forms, { fields: [fieldHistory.formId], references: [forms.id], }), changedBy: one(users, { fields: [fieldHistory.changedBy], references: [users.id], }), })); export const taskMessagesRelations = relations(taskMessages, ({ one, many }) => ({ task: one(tasks, { fields: [taskMessages.taskId], references: [tasks.id], }), form: one(forms, { fields: [taskMessages.formId], references: [forms.id], }), author: one(users, { fields: [taskMessages.authorId], references: [users.id], }), replyToMessage: one(taskMessages, { fields: [taskMessages.replyToMessageId], references: [taskMessages.id], relationName: "messageReplies", }), replies: many(taskMessages, { relationName: "messageReplies", }), })); export const userNotificationsRelations = relations(userNotifications, ({ one }) => ({ user: one(users, { fields: [userNotifications.userId], references: [users.id], }), organization: one(organizations, { fields: [userNotifications.organizationId], references: [organizations.id], }), task: one(tasks, { fields: [userNotifications.taskId], references: [tasks.id], }), taskMessage: one(taskMessages, { fields: [userNotifications.messageId], references: [taskMessages.id], }), })); export const messageReadsRelations = relations(messageReads, ({ one }) => ({ message: one(taskMessages, { fields: [messageReads.messageId], references: [taskMessages.id], }), user: one(users, { fields: [messageReads.userId], references: [users.id], }), organization: one(organizations, { fields: [messageReads.organizationId], references: [organizations.id], }), })); // Insert schemas export const insertOrganizationSchema = createInsertSchema(organizations).omit({ id: true, createdAt: true, updatedAt: true, }); export const insertUserSchema = createInsertSchema(users).omit({ id: true, createdAt: true, updatedAt: true, }); export const insertUserStatusSchema = createInsertSchema(userStatuses).omit({ id: true, createdAt: true, }); export const updateUserStatusSchema = createInsertSchema(userStatuses).omit({ id: true, organizationId: true, createdAt: true, }); export const insertUserSessionSchema = createInsertSchema(userSessions).omit({ id: true, createdAt: true, }); export const insertInvitationSchema = createInsertSchema(invitations).omit({ id: true, createdAt: true, }); export const createInvitationSchema = z.object({ email: z.string().email("Неверный формат email"), }); export const registerByInvitationSchema = z.object({ token: z.string().min(1, "Токен обязателен"), firstName: z.string().min(1, "Имя обязательно"), lastName: z.string().min(1, "Фамилия обязательна"), password: z.string() .min(8, "Пароль должен содержать минимум 8 символов") .regex(/[A-Z]/, "Пароль должен содержать хотя бы одну заглавную букву") .regex(/[0-9]/, "Пароль должен содержать хотя бы одну цифру"), position: z.string().optional(), }); // User custom fields schemas export const createUserCustomFieldSchema = z.object({ name: z.string().min(1, "Название обязательно"), code: z.string().min(1, "Код обязателен").regex(/^[a-z_][a-z0-9_]*$/, "Код должен содержать только латинские буквы, цифры и подчеркивания"), type: z.enum(["text", "number", "date", "select", "user", "checkbox"]), options: z.array(z.string()).optional(), isRequired: z.boolean().optional(), }); export const updateUserCustomFieldSchema = createUserCustomFieldSchema.partial(); export const setUserCustomValueSchema = z.object({ fieldId: z.number(), value: z.string().nullable(), }); export const insertUserProfileTabSchema = createInsertSchema(userProfileTabs).omit({ id: true, createdAt: true, }); export const insertUserProfileFieldSchema = createInsertSchema(userProfileFields).omit({ id: true, createdAt: true, }); export const insertUserProfileFieldValueSchema = createInsertSchema(userProfileFieldValues).omit({ id: true, createdAt: true, updatedAt: true, }); export const insertUserProfileAuditLogSchema = createInsertSchema(userProfileAuditLog).omit({ id: true, createdAt: true, }); export const insertUserFieldHistorySchema = createInsertSchema(userFieldHistory).omit({ id: true, createdAt: true, }); export const insertFormSchema = createInsertSchema(forms).omit({ id: true, createdAt: true, updatedAt: true, }); export const updateFormSchema = createInsertSchema(forms).omit({ id: true, organizationId: true, createdBy: true, createdAt: true, updatedAt: true, }).partial(); export const insertFormTabSchema = createInsertSchema(formTabs).omit({ id: true, createdAt: true, }); export const fieldRuleStatusModeSchema = z.enum([ "any", "creation", "in", "in_or_creation", ]); export const fieldRuleStatusConditionSchema = z.object({ mode: fieldRuleStatusModeSchema, statusIds: z.array(z.number()).optional(), }); export const fieldRuleSchema = z.object({ enabled: z.boolean().default(false), statusCondition: fieldRuleStatusConditionSchema.optional().nullable(), statusId: z.union([z.number(), z.literal("__creation__")]).nullable().optional(), logic: z.enum(["and", "or"]).default("and"), conditions: z.array( z.object({ fieldCode: z.string(), operator: z.enum(["contains", "empty", "filled"]).default("contains"), value: z.string().optional(), }) ).default([]), }); export const insertFormFieldSchema = createInsertSchema(formFields).omit({ id: true, createdAt: true, }).extend({ requiredRules: fieldRuleSchema.optional().nullable(), immutableRules: fieldRuleSchema.optional().nullable(), visibilityRules: fieldRuleSchema.optional().nullable(), }); export const insertFormStatusSchema = createInsertSchema(formStatuses).omit({ id: true, createdAt: true, }); export const insertStatusTransitionSchema = createInsertSchema(statusTransitions).omit({ id: true, createdAt: true, }); export const insertTaskSchema = createInsertSchema(tasks).omit({ id: true, createdAt: true, updatedAt: true, }).extend({ organizationId: z.number().optional(), // Переопределяем dueDate чтобы принимать строку и конвертировать в Date // Поддерживаем разные форматы: ISO, YYYY-MM-DD, DD.MM.YYYY dueDate: z.union([z.string(), z.date(), z.null(), z.undefined()]).optional().transform(val => { if (!val || val === '') return null; if (val instanceof Date) return val; // Попробуем распарсить как ISO или YYYY-MM-DD const isoDate = new Date(val); if (!isNaN(isoDate.getTime())) return isoDate; // Попробуем DD.MM.YYYY формат const parts = val.split('.'); if (parts.length === 3) { const [day, month, year] = parts; return new Date(parseInt(year), parseInt(month) - 1, parseInt(day)); } return null; }), completedAt: z.union([z.string(), z.date(), z.null(), z.undefined()]).optional().transform(val => { if (!val || val === '') return null; if (val instanceof Date) return val; return new Date(val); }), customFields: z.record(z.any()).optional(), }); export const insertTaskFieldValueSchema = createInsertSchema(taskFieldValues).omit({ id: true, createdAt: true, updatedAt: true, }); export const insertFieldHistorySchema = createInsertSchema(fieldHistory).omit({ id: true, changedAt: true, }); export const insertTaskMessageSchema = createInsertSchema(taskMessages).omit({ id: true, createdAt: true, }); export const insertUserNotificationSchema = createInsertSchema(userNotifications).omit({ id: true, createdAt: true, }); export const insertMessageReadSchema = createInsertSchema(messageReads).omit({ id: true, readAt: true, }); // Registration schemas export const registerOrganizationSchema = z.object({ organizationName: z.string().min(1, "Название организации обязательно"), organizationSlug: z.string().min(1, "Код организации обязателен").regex(/^[a-z0-9-]+$/, "Код может содержать только латинские буквы, цифры и дефисы"), adminEmail: z.string().email("Неверный формат email"), adminFirstName: z.string().min(1, "Имя обязательно"), adminLastName: z.string().min(1, "Фамилия обязательна"), adminPassword: z.string().min(8, "Пароль должен содержать минимум 8 символов"), }); export const loginSchema = z.object({ email: z.string().email("Неверный формат email"), password: z.string().min(1, "Пароль обязателен"), organizationSlug: z.string().optional(), remember: z.boolean().default(false), }); // Form creation schemas export const createFormSchema = z.object({ name: z.string().min(1, "Название формы обязательно").max(200, "Название не может быть длиннее 200 символов"), description: z.string().optional(), fields: z.array(z.object({ name: z.string().min(1, "Название поля обязательно").max(200), code: z.string().min(1, "Код поля обязателен").max(100).regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/, "Код поля может содержать только буквы, цифры и подчеркивания"), type: z.enum(['text', 'number', 'date', 'select', 'checkbox', 'toggle', 'button', 'user', 'file', 'textarea', 'rich-text', 'datetime', 'history-number', 'document_template']), isRequired: z.boolean().default(false), placeholder: z.string().optional(), defaultValue: z.string().optional(), validationRules: z.record(z.any()).optional(), options: z.array(z.object({ id: z.string(), label: z.string(), value: z.string().optional(), })).optional(), position: z.number().default(0), })).default([]), statuses: z.array(z.object({ name: z.string().min(1, "Название статуса обязательно").max(100), color: z.string().regex(/^#[0-9a-fA-F]{6}$/, "Цвет должен быть в формате #RRGGBB").default("#e5e7eb"), position: z.number().default(0), isInitial: z.boolean().default(false), isFinal: z.boolean().default(false), entryConditions: z.array(z.object({ fieldCode: z.string(), value: z.string(), operator: z.enum(["contains", "empty", "filled"]).optional(), })).optional(), })).min(1, "Форма должна содержать минимум один статус"), }).superRefine((data, ctx) => { // Проверка уникальности кодов полей const fieldCodes = data.fields.map(f => f.code); const duplicatedCodes = fieldCodes.filter((code, index) => fieldCodes.indexOf(code) !== index); if (duplicatedCodes.length > 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Коды полей должны быть уникальными: ${Array.from(new Set(duplicatedCodes)).join(', ')}`, path: ['fields'] }); } // Проверка что есть ровно один начальный статус const initialStatuses = data.statuses.filter(s => s.isInitial); if (initialStatuses.length === 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Должен быть определен один начальный статус", path: ['statuses'] }); } else if (initialStatuses.length > 1) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Может быть только один начальный статус", path: ['statuses'] }); } // Проверка что у select полей есть options data.fields.forEach((field, index) => { if (field.type === 'select' && (!field.options || field.options.length === 0)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Select поля должны содержать опции", path: ['fields', index, 'options'] }); } }); }); export const createTaskSchema = z.object({ formId: z.number().positive("ID формы обязателен"), title: z.string().min(1, "Название задачи обязательно").max(500), description: z.string().optional(), assignedTo: z.number().optional(), dueDate: z.string().datetime().optional(), fieldValues: z.record(z.any()).optional(), }); export const updateTaskSchema = z.object({ title: z.string().min(1).max(500).optional(), description: z.string().optional(), assignedTo: z.number().optional(), dueDate: z.string().datetime().optional().nullable(), fieldValues: z.record(z.any()).optional(), }); export const transitionTaskSchema = z.object({ toStatusId: z.number().positive("ID статуса обязателен"), comment: z.string().optional(), fieldValues: z.record(z.any()).optional(), }); export const chatAttachmentSchema = z.object({ url: z.string().refine( (url) => url.startsWith('/uploads/') || url.startsWith('/api/files/') || /^https?:\/\//.test(url), { message: "Недопустимый URL вложения" } ), name: z.string().max(255), size: z.number().nonnegative(), mimeType: z.string().max(100).optional(), }); export const createTaskMessageSchema = z.object({ taskId: z.number().positive("ID задачи обязателен"), replyToMessageId: z.number().int().positive().optional(), message: z.string().max(10000, "Сообщение слишком длинное").default(""), messageType: z.enum(['comment', 'system', 'status_change']).default('comment'), mentionedUserIds: z.array(z.number().int().positive()).max(20).optional(), attachments: z.array(chatAttachmentSchema).max(10).optional(), clientMessageId: z.string().max(64).optional(), }); export const searchUsersSchema = z.object({ query: z.string().min(1, "Поисковый запрос обязателен").max(50), limit: z.number().min(1).max(20).default(10), }); // Types export type Organization = typeof organizations.$inferSelect; export type User = typeof users.$inferSelect; // Пользователь без чувствительных полей — безопасен для массовой выдачи клиенту export type SafeUser = Omit; export type UserSession = typeof userSessions.$inferSelect; export type Invitation = typeof invitations.$inferSelect; export type Form = typeof forms.$inferSelect; export type FormTab = typeof formTabs.$inferSelect; export type FormField = typeof formFields.$inferSelect; export type FormStatus = typeof formStatuses.$inferSelect; export type StatusTransition = typeof statusTransitions.$inferSelect; export type Task = typeof tasks.$inferSelect; export type TaskFieldValue = typeof taskFieldValues.$inferSelect; export type FieldHistory = typeof fieldHistory.$inferSelect; export type TaskMessage = typeof taskMessages.$inferSelect; export type UserNotification = typeof userNotifications.$inferSelect; export type MessageRead = typeof messageReads.$inferSelect; export type InsertOrganization = z.infer; export type InsertUser = z.infer; export type InsertUserSession = z.infer; export type InsertInvitation = z.infer; export type CreateInvitation = z.infer; export type RegisterByInvitation = z.infer; export type UserCustomField = typeof userCustomFields.$inferSelect; export type UserCustomValue = typeof userCustomValues.$inferSelect; export type CreateUserCustomField = z.infer; export type UpdateUserCustomField = z.infer; export type SetUserCustomValue = z.infer; export type UserProfileTab = typeof userProfileTabs.$inferSelect; export type UserProfileField = typeof userProfileFields.$inferSelect; export type UserProfileFieldValue = typeof userProfileFieldValues.$inferSelect; export type UserProfileAuditLog = typeof userProfileAuditLog.$inferSelect; export type UserFieldHistory = typeof userFieldHistory.$inferSelect; export type InsertUserProfileTab = z.infer; export type InsertUserProfileField = z.infer; export type InsertUserProfileFieldValue = z.infer; export type InsertUserProfileAuditLog = z.infer; export type InsertUserFieldHistory = z.infer; export type InsertForm = z.infer; export type InsertFormTab = z.infer; export type InsertFormField = z.infer; export type InsertFormStatus = z.infer; export type InsertStatusTransition = z.infer; export type InsertTask = z.infer; export type InsertTaskFieldValue = z.infer; export type InsertFieldHistory = z.infer; export type InsertTaskMessage = z.infer; export type InsertUserNotification = z.infer; export type InsertMessageRead = z.infer; export type RegisterOrganization = z.infer; export type LoginRequest = z.infer; export type CreateFormRequest = z.infer; export type CreateTaskRequest = z.infer; export type UpdateTaskRequest = z.infer; export type TransitionTaskRequest = z.infer; export type CreateTaskMessageRequest = z.infer; export type UserWithOrganization = User & { organization: Organization; }; export type FormWithFields = Form & { fields: FormField[]; tabs?: FormTab[]; statuses: FormStatus[]; transitions?: StatusTransition[]; }; export type TaskWithDetails = Task & { form: Form; currentStatus: FormStatus; assignedTo?: User; createdBy: User; fieldValues: TaskFieldValue[]; }; export type TaskMessageWithAuthor = TaskMessage & { author: { id: number; firstName: string; lastName: string; middleName: string | null; }; bot?: { id: number; name: string; avatarUrl: string | null; }; replyToMessage?: { id: number; message: string; author?: { firstName: string; lastName: string; }; }; }; // Bookmark folders table export const bookmarkFolders = pgTable("bookmark_folders", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), // владелец закладки (личные закладки) name: varchar("name", { length: 200 }).notNull(), parentId: integer("parent_id"), // для вложенности position: integer("position").notNull().default(0), // порядок отображения isExpanded: boolean("is_expanded").default(true), // раскрыта ли папка createdBy: integer("created_by").notNull().references(() => users.id), createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), }); // TODO: Add proper FK constraints later // Bookmarks table export const bookmarks = pgTable("bookmarks", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), // владелец закладки (личные закладки) folderId: integer("folder_id").references(() => bookmarkFolders.id, { onDelete: "cascade" }), // папка (может быть null для корневого уровня) name: varchar("name", { length: 200 }).notNull(), type: varchar("type", { length: 50 }).notNull(), // 'form', 'task', 'kanban', 'report', 'dashboard', 'custom_url' targetId: integer("target_id"), // ID целевого объекта (формы, задачи и т.д.) formId: integer("form_id").references(() => forms.id), // ID формы (для задач - чтобы формировать полный путь) url: varchar("url", { length: 1000 }), // URL для пользовательских закладок icon: varchar("icon", { length: 100 }), // название иконки Lucide position: integer("position").notNull().default(0), // порядок отображения viewConfig: jsonb("view_config"), // конфигурация отображения (для канбан, фильтры и т.д.) createdBy: integer("created_by").notNull().references(() => users.id), createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), }); // TODO: Add proper FK constraints later // Relations for bookmarks system export const bookmarkFoldersRelations = relations(bookmarkFolders, ({ one, many }) => ({ organization: one(organizations, { fields: [bookmarkFolders.organizationId], references: [organizations.id], }), parent: one(bookmarkFolders, { fields: [bookmarkFolders.parentId], references: [bookmarkFolders.id], relationName: "folderParent", }), children: many(bookmarkFolders, { relationName: "folderParent" }), bookmarks: many(bookmarks), createdBy: one(users, { fields: [bookmarkFolders.createdBy], references: [users.id], }), })); export const bookmarksRelations = relations(bookmarks, ({ one }) => ({ organization: one(organizations, { fields: [bookmarks.organizationId], references: [organizations.id], }), folder: one(bookmarkFolders, { fields: [bookmarks.folderId], references: [bookmarkFolders.id], }), createdBy: one(users, { fields: [bookmarks.createdBy], references: [users.id], }), })); // Insert schemas for bookmarks export const insertBookmarkFolderSchema = createInsertSchema(bookmarkFolders).omit({ id: true, createdAt: true, updatedAt: true, }).extend({ userId: z.number({ required_error: "userId is required and must be a number" }), createdBy: z.number({ required_error: "createdBy is required and must be a number" }), organizationId: z.number({ required_error: "organizationId is required and must be a number" }), }); export const insertBookmarkSchema = createInsertSchema(bookmarks).omit({ id: true, createdAt: true, updatedAt: true, }).extend({ userId: z.number({ required_error: "userId is required and must be a number" }), createdBy: z.number({ required_error: "createdBy is required and must be a number" }), organizationId: z.number({ required_error: "organizationId is required and must be a number" }), url: z.string().max(1000).refine( (val) => { if (val.startsWith('/')) return true; try { new URL(val); return true; } catch { return false; } }, { message: "Укажите путь (/tasks) или полный URL (https://...)" } ).optional().nullable(), }); // Create/Update schemas export const createBookmarkFolderSchema = z.object({ name: z.string().min(1, "Название папки обязательно").max(200), parentId: z.number().optional(), position: z.number().default(0), }); export const updateBookmarkFolderSchema = createBookmarkFolderSchema.extend({ isExpanded: z.boolean().optional(), }).partial(); const urlOrPath = z.string().max(1000).refine( (val) => { if (val.startsWith('/')) return true; try { new URL(val); return true; } catch { return false; } }, { message: "Укажите путь (/tasks) или полный URL (https://...)" } ); export const createBookmarkSchema = z.object({ name: z.string().min(1, "Название закладки обязательно").max(200), folderId: z.number().optional(), type: z.enum(['form', 'task', 'kanban', 'report', 'dashboard', 'custom_url']), targetId: z.number().optional(), formId: z.number().optional(), url: urlOrPath.optional(), icon: z.string().max(100).optional(), position: z.number().default(0), viewConfig: z.record(z.any()).optional(), }).superRefine((data, ctx) => { if (data.type === 'custom_url' && !data.url) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "URL обязателен для пользовательских закладок", path: ['url'] }); } if (!['custom_url', 'dashboard'].includes(data.type) && !data.targetId) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "ID объекта обязателен для данного типа закладки", path: ['targetId'] }); } }); export const updateBookmarkSchema = z.object({ name: z.string().min(1, "Название закладки обязательно").max(200).optional(), folderId: z.number().optional(), type: z.enum(['form', 'task', 'kanban', 'report', 'dashboard', 'custom_url']).optional(), targetId: z.number().optional(), formId: z.number().optional(), url: urlOrPath.optional(), icon: z.string().max(100).optional(), position: z.number().optional(), viewConfig: z.record(z.any()).optional(), }).partial(); // Types for bookmarks export type BookmarkFolder = typeof bookmarkFolders.$inferSelect; export type Bookmark = typeof bookmarks.$inferSelect; export type InsertBookmarkFolder = z.infer; export type InsertBookmark = z.infer; export type CreateBookmarkFolder = z.infer; export type UpdateBookmarkFolder = z.infer; export type CreateBookmark = z.infer; export type UpdateBookmark = z.infer; // ===================================================== // BOTS SYSTEM - для интеграции с n8n и другими системами // ===================================================== // Bots table - боты для автоматизации export const bots = pgTable("bots", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), name: varchar("name", { length: 200 }).notNull(), description: text("description"), avatarUrl: varchar("avatar_url", { length: 500 }), // Credentials для авторизации бота через API login: varchar("login", { length: 100 }).notNull(), passwordHash: varchar("password_hash", { length: 255 }).notNull(), // Webhook для отправки событий в n8n webhookUrl: varchar("webhook_url", { length: 1000 }), webhookSecret: varchar("webhook_secret", { length: 255 }), // для подписи запросов HMAC webhookEnabled: boolean("webhook_enabled").default(true), // Тип бота: 'webhook' (n8n) или 'ai_assistant' type: varchar("type", { length: 20 }).notNull().default("webhook"), // AI-ассистент: системный промпт systemPrompt: text("system_prompt"), // AI-ассистент: RAG ragEnabled: boolean("rag_enabled").default(false), ragTopK: integer("rag_top_k").default(5), // AI-ассистент: список MCP-серверов [{url, apiKey, name}] mcpServers: jsonb("mcp_servers").$type>(), // AI-ассистент: политика доступа {mode: 'all'|'roles'|'users', roles?: string[], userIds?: number[]} accessPolicy: jsonb("access_policy").$type<{ mode: 'all' | 'roles' | 'users'; roles?: string[]; userIds?: number[] }>(), // LLM-провайдер из реестра (если задан — используется вместо глобальных настроек) llmProviderId: integer("llm_provider_id").references(() => llmProviders.id, { onDelete: "set null" }), llmModel: varchar("llm_model", { length: 200 }), // конкретная модель из разрешённых // AI-ассистент: настройки отправки контекста в LLM sendSystemPrompt: boolean("send_system_prompt").default(true), sendCardInfo: boolean("send_card_info").default(true), sendChatHistory: boolean("send_chat_history").default(true), // Настройки isActive: boolean("is_active").default(true), createdBy: integer("created_by").notNull().references(() => users.id), createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), }, (table) => ({ // Уникальный логин бота в рамках организации uniqueBotLogin: unique().on(table.organizationId, table.login), })); // Bot subscriptions - подписки бота на события export const botSubscriptions = pgTable("bot_subscriptions", { id: serial("id").primaryKey(), botId: integer("bot_id").notNull().references(() => bots.id, { onDelete: "cascade" }), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), // Тип подписки: form (все задачи формы), status (определенный статус), transition (определенный переход) subscriptionType: varchar("subscription_type", { length: 20 }).notNull(), // 'form', 'status', 'transition' // Привязки к объектам formId: integer("form_id").references(() => forms.id, { onDelete: "cascade" }), statusId: integer("status_id").references(() => formStatuses.id, { onDelete: "cascade" }), transitionId: integer("transition_id").references(() => statusTransitions.id, { onDelete: "cascade" }), // События для отслеживания (массив строк) events: jsonb("events").notNull().default(['comment', 'field_change', 'status_change']), // массив: comment, field_change, status_change, task_created, task_assigned // Фильтры (опционально) filters: jsonb("filters"), // дополнительные условия фильтрации isActive: boolean("is_active").default(true), createdAt: timestamp("created_at").defaultNow(), }); // Bot sessions - сессии ботов (для refresh tokens) export const botSessions = pgTable("bot_sessions", { id: serial("id").primaryKey(), botId: integer("bot_id").notNull().references(() => bots.id, { onDelete: "cascade" }), refreshToken: varchar("refresh_token", { length: 500 }).notNull().unique(), expiresAt: timestamp("expires_at").notNull(), createdAt: timestamp("created_at").defaultNow(), ipAddress: varchar("ip_address", { length: 45 }), userAgent: text("user_agent"), }); // Relations for bots export const botsRelations = relations(bots, ({ one, many }) => ({ organization: one(organizations, { fields: [bots.organizationId], references: [organizations.id], }), createdBy: one(users, { fields: [bots.createdBy], references: [users.id], }), subscriptions: many(botSubscriptions), sessions: many(botSessions), })); export const botSubscriptionsRelations = relations(botSubscriptions, ({ one }) => ({ bot: one(bots, { fields: [botSubscriptions.botId], references: [bots.id], }), organization: one(organizations, { fields: [botSubscriptions.organizationId], references: [organizations.id], }), form: one(forms, { fields: [botSubscriptions.formId], references: [forms.id], }), status: one(formStatuses, { fields: [botSubscriptions.statusId], references: [formStatuses.id], }), transition: one(statusTransitions, { fields: [botSubscriptions.transitionId], references: [statusTransitions.id], }), })); export const botSessionsRelations = relations(botSessions, ({ one }) => ({ bot: one(bots, { fields: [botSessions.botId], references: [bots.id], }), })); export const mcpServerSchema = z.object({ url: z.string().url("Некорректный URL MCP-сервера"), apiKey: z.string().optional(), name: z.string().max(100).optional(), }); // Insert schemas for bots // Override JSONB fields with proper Zod types (drizzle-zod maps jsonb to z.unknown()) export const insertBotSchema = createInsertSchema(bots).omit({ id: true, createdAt: true, updatedAt: true, }).extend({ mcpServers: z.array(mcpServerSchema).optional().nullable(), accessPolicy: z.object({ mode: z.enum(['all', 'roles', 'users']), roles: z.array(z.string()).optional(), userIds: z.array(z.number()).optional(), }).optional().nullable(), }); export const insertBotSubscriptionSchema = createInsertSchema(botSubscriptions).omit({ id: true, createdAt: true, }); export const insertBotSessionSchema = createInsertSchema(botSessions).omit({ id: true, createdAt: true, }); const accessPolicySchema = z.object({ mode: z.enum(['all', 'roles', 'users']), roles: z.array(z.string()).optional(), userIds: z.array(z.number()).optional(), }); // Настройки API-доступа бота (ключ organization_api_keys, привязанный к боту 1:1) export const botApiAccessSchema = z.object({ enabled: z.boolean(), mode: z.enum(['read', 'write', 'full']).optional(), formIds: z.array(z.number().int()).nullable().optional(), tableIds: z.array(z.number().int()).nullable().optional(), }); // Create/Update schemas for bots export const createBotSchema = z.object({ name: z.string().min(1, "Название бота обязательно").max(200), description: z.string().max(1000).optional(), avatarUrl: z.string().url("Неверный формат URL").max(500).optional(), login: z.string().min(3, "Логин должен содержать минимум 3 символа").max(100).regex(/^[a-zA-Z0-9_-]+$/, "Логин может содержать только буквы, цифры, дефис и подчеркивание").optional(), password: z.string().min(8, "Пароль должен содержать минимум 8 символов").optional(), webhookUrl: z.string().url("Неверный формат URL").max(1000).optional().or(z.literal('')).transform(v => v || undefined), webhookSecret: z.string().max(255).optional(), type: z.enum(['webhook', 'ai_assistant']).optional().default('webhook'), systemPrompt: z.string().max(10000).optional().nullable(), ragEnabled: z.boolean().optional(), ragTopK: z.number().int().min(1).max(50).optional(), mcpServers: z.array(mcpServerSchema).optional().nullable(), accessPolicy: accessPolicySchema.optional().nullable(), llmProviderId: z.number().int().positive().optional().nullable(), llmModel: z.string().max(200).optional().nullable(), sendSystemPrompt: z.boolean().optional(), sendCardInfo: z.boolean().optional(), sendChatHistory: z.boolean().optional(), apiAccess: botApiAccessSchema.optional(), }); export const updateBotSchema = z.object({ name: z.string().min(1).max(200).optional(), description: z.string().max(1000).optional(), avatarUrl: z.string().url().max(500).optional().nullable(), webhookUrl: z.string().url().max(1000).optional().nullable(), webhookSecret: z.string().max(255).optional().nullable(), webhookEnabled: z.boolean().optional(), isActive: z.boolean().optional(), type: z.enum(['webhook', 'ai_assistant']).optional(), systemPrompt: z.string().max(10000).optional().nullable(), ragEnabled: z.boolean().optional(), ragTopK: z.number().int().min(1).max(50).optional(), mcpServers: z.array(mcpServerSchema).optional().nullable(), accessPolicy: accessPolicySchema.optional().nullable(), llmProviderId: z.number().int().positive().optional().nullable(), llmModel: z.string().max(200).optional().nullable(), sendSystemPrompt: z.boolean().optional(), sendCardInfo: z.boolean().optional(), sendChatHistory: z.boolean().optional(), apiAccess: botApiAccessSchema.optional(), }).partial(); export const createBotSubscriptionSchema = z.object({ subscriptionType: z.enum(['form', 'status', 'transition']), formId: z.number().positive().optional(), statusId: z.number().positive().optional(), transitionId: z.number().positive().optional(), events: z.array(z.enum(['comment', 'field_change', 'status_change', 'task_created', 'task_assigned'])).min(1, "Выберите хотя бы одно событие"), filters: z.record(z.any()).optional(), }).superRefine((data, ctx) => { // Проверка что указан соответствующий ID для типа подписки if (data.subscriptionType === 'form' && !data.formId) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "ID формы обязателен для подписки на форму", path: ['formId'] }); } if (data.subscriptionType === 'status' && !data.statusId) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "ID статуса обязателен для подписки на статус", path: ['statusId'] }); } if (data.subscriptionType === 'transition' && !data.transitionId) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "ID перехода обязателен для подписки на переход", path: ['transitionId'] }); } }); export const botLoginSchema = z.object({ login: z.string().min(1, "Логин обязателен"), password: z.string().min(1, "Пароль обязателен"), organizationSlug: z.string().min(1, "Код организации обязателен"), }); // Bot message schema - для сообщений с кнопками от бота export const botMessageSchema = z.object({ taskId: z.number().positive("ID задачи обязателен"), message: z.string().min(1, "Сообщение не может быть пустым").max(10000), buttons: z.array(z.object({ text: z.string().min(1).max(50), callbackData: z.string().min(1).max(100), // данные которые отправятся при нажатии style: z.enum(['primary', 'secondary', 'danger', 'success']).default('primary'), row: z.number().min(0).max(4).default(0), // номер ряда для группировки кнопок })).max(10).optional(), replyToMessageId: z.number().positive().optional(), }); // Types for bots export type Bot = typeof bots.$inferSelect; export type BotSubscription = typeof botSubscriptions.$inferSelect; export type BotSession = typeof botSessions.$inferSelect; export type InsertBot = z.infer; export type InsertBotSubscription = z.infer; export type InsertBotSession = z.infer; export type CreateBot = z.infer; export type UpdateBot = z.infer; export type CreateBotSubscription = z.infer; export type BotLogin = z.infer; export type BotMessage = z.infer; export type BotWithSubscriptions = Bot & { subscriptions: BotSubscription[]; }; // Data tables (справочники/каталоги) export const dataTables = pgTable("data_tables", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), name: varchar("name", { length: 200 }).notNull(), description: text("description"), columns: jsonb("columns").notNull().$type>(), createdBy: integer("created_by").notNull().references(() => users.id), createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), isDeleted: boolean("is_deleted").default(false), visibility: varchar("visibility", { length: 20 }).default("restricted"), folderId: integer("folder_id"), treeColumnIndex: integer("tree_column_index"), treeDisplayColumnIndex: integer("tree_display_column_index"), treeColumns: jsonb("tree_columns").$type().default(null), treeDisplayColumns: jsonb("tree_display_columns").$type().default(null), }, (table) => ({ folderIdx: index("data_tables_folder_idx").on(table.folderId), })); // Data table links (подсправочники / связанные справочники) export const dataTableLinks = pgTable("data_table_links", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), parentTableId: integer("parent_table_id").notNull().references(() => dataTables.id, { onDelete: "cascade" }), childTableId: integer("child_table_id").notNull().references(() => dataTables.id, { onDelete: "cascade" }), parentColumnIndex: integer("parent_column_index").notNull().default(0), childColumnIndex: integer("child_column_index").notNull().default(0), parentRowIds: jsonb("parent_row_ids").notNull().$type().default([]), createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), }, (table) => ({ parentIdx: index("data_table_links_parent_idx").on(table.parentTableId), childIdx: index("data_table_links_child_idx").on(table.childTableId), uniqueParentChild: unique("data_table_links_parent_child_unique").on(table.parentTableId, table.childTableId), })); // Directory/table folders table export const directoryFolders = pgTable("directory_folders", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), name: varchar("name", { length: 200 }).notNull(), parentId: integer("parent_id"), position: integer("position").notNull().default(0), createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), }, (table) => ({ orgParentIdx: index("directory_folders_org_parent_idx").on(table.organizationId, table.parentId), positionIdx: index("directory_folders_position_idx").on(table.position), })); export type DirectoryFolder = typeof directoryFolders.$inferSelect; export type InsertDirectoryFolder = typeof directoryFolders.$inferInsert; // Data table rows (строки справочника) export const dataTableRows = pgTable("data_table_rows", { id: serial("id").primaryKey(), tableId: integer("table_id").notNull().references(() => dataTables.id, { onDelete: "cascade" }), parentId: integer("parent_id"), values: jsonb("values").notNull().$type(), position: integer("position").notNull().default(0), // порядок строки в таблице createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), isDeleted: boolean("is_deleted").default(false), }, (table) => ({ tableParentPositionIdx: index("data_table_rows_table_parent_position_idx").on(table.tableId, table.parentId, table.position), })); // Data table permissions (права доступа) export const dataTablePermissions = pgTable("data_table_permissions", { id: serial("id").primaryKey(), tableId: integer("table_id").notNull().references(() => dataTables.id, { onDelete: "cascade" }), userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), role: varchar("role", { length: 20 }).notNull(), // 'admin', 'editor', 'reader' createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ uniqueUserTable: unique().on(table.tableId, table.userId), })); // Data table access rules (role-based access to directories, analogous to form_access_rules) export const dataTableAccessRules = pgTable("data_table_access_rules", { id: serial("id").primaryKey(), tableId: integer("table_id").notNull().references(() => dataTables.id, { onDelete: "cascade" }), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), targetType: varchar("target_type", { length: 10 }).notNull(), // 'user' | 'role' targetId: integer("target_id").notNull(), accessLevel: varchar("access_level", { length: 20 }).notNull().default("reader"), // 'reader' | 'editor' | 'admin' createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ uniqueTableTarget: unique("data_table_access_rules_unique").on(table.tableId, table.targetType, table.targetId), })); // Relations for data tables export const dataTablesRelations = relations(dataTables, ({ one, many }) => ({ organization: one(organizations, { fields: [dataTables.organizationId], references: [organizations.id], }), createdByUser: one(users, { fields: [dataTables.createdBy], references: [users.id], }), folder: one(directoryFolders, { fields: [dataTables.folderId], references: [directoryFolders.id], }), rows: many(dataTableRows), permissions: many(dataTablePermissions), parentLinks: many(dataTableLinks, { relationName: "parentLinks" }), childLinks: many(dataTableLinks, { relationName: "childLinks" }), })); export const dataTableLinksRelations = relations(dataTableLinks, ({ one }) => ({ organization: one(organizations, { fields: [dataTableLinks.organizationId], references: [organizations.id], }), parentTable: one(dataTables, { fields: [dataTableLinks.parentTableId], references: [dataTables.id], relationName: "parentLinks", }), childTable: one(dataTables, { fields: [dataTableLinks.childTableId], references: [dataTables.id], relationName: "childLinks", }), })); export const directoryFoldersRelations = relations(directoryFolders, ({ one, many }) => ({ organization: one(organizations, { fields: [directoryFolders.organizationId], references: [organizations.id], }), parent: one(directoryFolders, { fields: [directoryFolders.parentId], references: [directoryFolders.id], }), children: many(directoryFolders), tables: many(dataTables), })); export const dataTableRowsRelations = relations(dataTableRows, ({ one, many }) => ({ table: one(dataTables, { fields: [dataTableRows.tableId], references: [dataTables.id], }), parent: one(dataTableRows, { fields: [dataTableRows.parentId], references: [dataTableRows.id], }), children: many(dataTableRows), })); export const dataTablePermissionsRelations = relations(dataTablePermissions, ({ one }) => ({ table: one(dataTables, { fields: [dataTablePermissions.tableId], references: [dataTables.id], }), user: one(users, { fields: [dataTablePermissions.userId], references: [users.id], }), })); // Insert schemas for data tables export const insertDataTableSchema = createInsertSchema(dataTables).omit({ id: true, organizationId: true, createdBy: true, createdAt: true, updatedAt: true, isDeleted: true, treeColumns: true, treeDisplayColumns: true, }).extend({ columns: z.array(z.object({ name: z.string(), type: z.enum(['text', 'number', 'date', 'checkbox', 'select', 'user', 'datetime']), options: z.array(z.string()).optional(), isRequired: z.boolean().optional(), })), }); export const insertDataTableRowSchema = createInsertSchema(dataTableRows).omit({ id: true, createdAt: true, updatedAt: true, isDeleted: true, }).extend({ values: z.array(z.string()), }); export const insertDataTablePermissionSchema = createInsertSchema(dataTablePermissions).omit({ id: true, createdAt: true, }); export const insertDataTableAccessRuleSchema = createInsertSchema(dataTableAccessRules).omit({ id: true, createdAt: true, }); // Validation schemas export const createDataTableSchema = z.object({ name: z.string().min(1, "Название таблицы обязательно").max(200), description: z.string().max(1000).optional(), columns: z.array(z.object({ name: z.string().min(1, "Название колонки обязательно"), type: z.enum(['text', 'number', 'date', 'checkbox', 'select', 'user', 'datetime']), options: z.array(z.string()).optional(), isRequired: z.boolean().optional(), })).min(1, "Требуется хотя бы одна колонка"), }); export const updateDataTableSchema = createDataTableSchema.partial(); export const createDataTableRowSchema = z.object({ values: z.array(z.string()), }); export const updateDataTableRowSchema = createDataTableRowSchema; export const setTablePermissionSchema = z.object({ userId: z.number().positive(), role: z.enum(['admin', 'editor', 'reader']), }); // Import/sync schemas export const syncDataTableSchema = z.object({ apply: z.boolean().default(false), columns: z.array(z.string()).optional(), rows: z.array(z.object({ values: z.array(z.string()), })), }); export const diffDataTableSchema = z.object({ upsert: z.array(z.object({ values: z.array(z.string()), })).optional(), delete: z.array(z.string()).optional(), }); // Types for data tables export type DataTable = typeof dataTables.$inferSelect; export type DataTableRow = typeof dataTableRows.$inferSelect; export type DataTablePermission = typeof dataTablePermissions.$inferSelect; export type DataTableAccessRule = typeof dataTableAccessRules.$inferSelect; export type DataTableLink = typeof dataTableLinks.$inferSelect; export type InsertDataTableLink = typeof dataTableLinks.$inferInsert; export type InsertDataTable = z.infer; export type InsertDataTableRow = z.infer; export type InsertDataTablePermission = z.infer; export type InsertDataTableAccessRule = typeof dataTableAccessRules.$inferInsert; export type CreateDataTable = z.infer; export type UpdateDataTable = z.infer; export type CreateDataTableRow = z.infer; export type SetTablePermission = z.infer; export type SyncDataTable = z.infer; export type DiffDataTable = z.infer; export type DataTableSubdirectory = DataTableLink & { childTable?: { id: number; name: string; columns: DataTable['columns'] } | null; }; export type DataTableWithRows = DataTable & { rows: DataTableRow[]; }; export type DataTableWithPermissions = DataTable & { permissions: (DataTablePermission & { user: { id: number; firstName: string; lastName: string; email: string } })[]; }; export type DataTableFull = DataTable & { rows: DataTableRow[]; permissions: (DataTablePermission & { user: { id: number; firstName: string; lastName: string; email: string } })[]; subdirectories: DataTableSubdirectory[]; }; // ======================================== // External Services Table // ======================================== // External services - сторонние сервисы и их API ключи для организаций export const externalServices = pgTable("external_services", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), serviceType: varchar("service_type", { length: 50 }).notNull(), // 'dadata', 'sms', 'email', etc. serviceName: varchar("service_name", { length: 200 }).notNull(), // Отображаемое название apiKey: text("api_key"), // Зашифрованный API ключ secretKey: text("secret_key"), // Зашифрованный секретный ключ (если есть) config: jsonb("config"), // Дополнительные настройки в формате JSON isActive: boolean("is_active").default(true), createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), }, (table) => ({ uniqueServicePerOrg: unique("external_services_organization_id_service_type_key").on(table.organizationId, table.serviceType), orgServiceIndex: index("external_services_org_idx").on(table.organizationId, table.isActive), })); // Relations for external services export const externalServicesRelations = relations(externalServices, ({ one }) => ({ organization: one(organizations, { fields: [externalServices.organizationId], references: [organizations.id], }), })); // Insert schema for external services export const insertExternalServiceSchema = createInsertSchema(externalServices).omit({ id: true, createdAt: true, updatedAt: true, }); // Types for external services export type ExternalService = typeof externalServices.$inferSelect; export type InsertExternalService = z.infer; // ======================================== // Event-Driven Notification System Tables // ======================================== // Notification event types - справочник типов событий export const notificationEventTypes = pgTable("notification_event_types", { id: serial("id").primaryKey(), code: varchar("code", { length: 100 }).notNull().unique(), // 'task.status.changed', 'task.comment.created', etc. name: varchar("name", { length: 200 }).notNull(), // Человекочитаемое название description: text("description"), // Описание события category: varchar("category", { length: 50 }).notNull(), // 'task', 'form', 'chat', 'system' isActive: boolean("is_active").default(true), createdAt: timestamp("created_at").defaultNow(), }); // User subscriptions - подписки пользователей на события export const notificationSubscriptions = pgTable("notification_subscriptions", { id: serial("id").primaryKey(), userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), eventTypeCode: varchar("event_type_code", { length: 100 }).notNull(), // код типа события targetType: varchar("target_type", { length: 50 }), // 'form', 'task', 'all' - на что подписка targetId: integer("target_id"), // ID формы или задачи (null = подписка на все) channels: jsonb("channels").default(sql`'["app", "email"]'::jsonb`), // ['app', 'email', 'push'] isActive: boolean("is_active").default(true), createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), }, (table) => ({ // Уникальная подписка: один пользователь - один тип события - один target // Имя совпадает с именем в БД (PostgreSQL усекает до 63 символов) uniqueSubscription: unique("notification_subscriptions_user_id_event_type_code_target_type_").on(table.userId, table.eventTypeCode, table.targetType, table.targetId), // Индексы для быстрого поиска userSubsIndex: index("notification_subs_user_idx").on(table.userId, table.organizationId), eventTypeIndex: index("notification_subs_event_idx").on(table.eventTypeCode, table.isActive), })); // Relations for notification subscriptions export const notificationEventTypesRelations = relations(notificationEventTypes, ({ many }) => ({ subscriptions: many(notificationSubscriptions), })); export const notificationSubscriptionsRelations = relations(notificationSubscriptions, ({ one }) => ({ user: one(users, { fields: [notificationSubscriptions.userId], references: [users.id], }), organization: one(organizations, { fields: [notificationSubscriptions.organizationId], references: [organizations.id], }), })); // Insert schemas for notification subscriptions export const insertNotificationEventTypeSchema = createInsertSchema(notificationEventTypes).omit({ id: true, createdAt: true, }); export const insertNotificationSubscriptionSchema = createInsertSchema(notificationSubscriptions).omit({ id: true, createdAt: true, updatedAt: true, }); // Validation schemas export const createSubscriptionSchema = z.object({ eventTypeCode: z.string().min(1, "Код типа события обязателен"), targetType: z.enum(['form', 'task', 'all']).optional(), targetId: z.number().positive().optional(), channels: z.array(z.enum(['app', 'email', 'push'])).default(['app']), // Целевой пользователь (только для админа/руководителя, проверка canManageUser в роуте) userId: z.number().int().positive().optional(), }); export const updateSubscriptionSchema = z.object({ channels: z.array(z.enum(['app', 'email', 'push'])).optional(), isActive: z.boolean().optional(), }); // Types for notification subscriptions export type NotificationEventType = typeof notificationEventTypes.$inferSelect; export type NotificationSubscription = typeof notificationSubscriptions.$inferSelect; export type InsertNotificationEventType = z.infer; export type InsertNotificationSubscription = z.infer; export type CreateSubscription = z.infer; export type UpdateSubscription = z.infer; // Extended types export type SubscriptionWithEventType = NotificationSubscription & { eventType?: NotificationEventType; }; // Event payload types for the event bus export type NotificationEvent = { type: string; // event type code organizationId: number; triggeredBy?: number | null; // userId who triggered the event (null for auto-transitions) taskId?: number; formId?: number; messageId?: number; payload: Record; mentionedUserIds?: number[]; targetUserIds?: number[]; // explicit recipients (bypass subscription check, used for reminders) timestamp: Date; }; // === REGULAR TABLE ROWS (for type='table' tabs) === // Строки обычных таблиц export const regularTableRows = pgTable("regular_table_rows", { id: serial("id").primaryKey(), taskId: integer("task_id").notNull().references(() => tasks.id, { onDelete: "cascade" }), tabId: integer("tab_id").notNull().references(() => formTabs.id, { onDelete: "cascade" }), position: integer("position").notNull().default(0), data: jsonb("data").notNull().default(sql`'{}'::jsonb`), // {columnId: value, ...} createdBy: integer("created_by").notNull().references(() => users.id), createdAt: timestamp("created_at").defaultNow(), updatedBy: integer("updated_by").references(() => users.id), updatedAt: timestamp("updated_at").defaultNow(), }, (table) => ({ // Индекс для быстрого получения строк таблицы taskTabIndex: index("regular_table_rows_task_tab_idx").on(table.taskId, table.tabId), })); // Relations export const regularTableRowsRelations = relations(regularTableRows, ({ one }) => ({ task: one(tasks, { fields: [regularTableRows.taskId], references: [tasks.id], }), tab: one(formTabs, { fields: [regularTableRows.tabId], references: [formTabs.id], }), createdByUser: one(users, { fields: [regularTableRows.createdBy], references: [users.id], relationName: "regularTableRowCreator", }), updatedByUser: one(users, { fields: [regularTableRows.updatedBy], references: [users.id], relationName: "regularTableRowUpdater", }), })); // Insert schema export const insertRegularTableRowSchema = createInsertSchema(regularTableRows).omit({ id: true, createdAt: true, updatedAt: true, }); // Types export type RegularTableRow = typeof regularTableRows.$inferSelect; export type InsertRegularTableRow = z.infer; // Table column definition type export type TableColumnDef = { id: string; name: string; type: 'text' | 'number' | 'date' | 'select' | 'table' | 'formula' | 'contract-number'; width: number; options?: string[]; dataTableId?: number; displayColumn?: string; formula?: string; aggregateFormula?: string; }; // ======================================== // Field Templates Table (Шаблоны полей) // ======================================== // Шаблоны полей - преднастроенные поля для быстрой вставки в формы export const fieldTemplates = pgTable("field_templates", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), code: varchar("code", { length: 100 }).notNull(), // уникальный код для API name: varchar("name", { length: 200 }).notNull(), type: varchar("type", { length: 50 }).notNull(), // text, number, date, select, checkbox, etc. options: jsonb("options"), // опции для select и других типов defaultValue: text("default_value"), isRequired: boolean("is_required").default(false), placeholder: text("placeholder"), description: text("description"), validationRules: jsonb("validation_rules"), companyAutofill: jsonb("company_autofill"), // настройки автозаполнения для типа company createdBy: integer("created_by").notNull().references(() => users.id), createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), }, (table) => ({ // Уникальность кода шаблона в организации uniqueCodePerOrg: unique("field_templates_organization_id_code_key").on(table.organizationId, table.code), // Индекс для быстрого поиска по организации orgIndex: index("field_templates_org_idx").on(table.organizationId), })); // Relations export const fieldTemplatesRelations = relations(fieldTemplates, ({ one }) => ({ organization: one(organizations, { fields: [fieldTemplates.organizationId], references: [organizations.id], }), createdByUser: one(users, { fields: [fieldTemplates.createdBy], references: [users.id], }), })); // Insert schema export const insertFieldTemplateSchema = createInsertSchema(fieldTemplates).omit({ id: true, createdAt: true, updatedAt: true, }); // Types export type FieldTemplate = typeof fieldTemplates.$inferSelect; export type InsertFieldTemplate = z.infer; // ===================== // Custom Tab Modules (Declarative) // ===================== export const customTabModules = pgTable("custom_tab_modules", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), type: varchar("type", { length: 100 }).notNull(), // unique identifier label: varchar("label", { length: 200 }).notNull(), // display name icon: varchar("icon", { length: 50 }).default('Puzzle'), // lucide icon name description: text("description"), config: jsonb("config").notNull(), // declarative configuration (render settings) inputSchema: jsonb("input_schema"), // schema of editable fields for per-task input isActive: boolean("is_active").default(true), createdBy: integer("created_by").notNull().references(() => users.id), createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), }, (table) => ({ // Имя constraint совпадает с именем существующего индекса в БД uniqueTypePerOrg: unique("custom_tab_modules_organization_id_type_key").on(table.organizationId, table.type), orgIndex: index("custom_tab_modules_org_idx").on(table.organizationId), })); export const customTabModulesRelations = relations(customTabModules, ({ one }) => ({ organization: one(organizations, { fields: [customTabModules.organizationId], references: [organizations.id], }), createdByUser: one(users, { fields: [customTabModules.createdBy], references: [users.id], }), })); export const insertCustomTabModuleSchema = createInsertSchema(customTabModules).omit({ id: true, createdAt: true, updatedAt: true, }); export type CustomTabModule = typeof customTabModules.$inferSelect; export type InsertCustomTabModule = z.infer; // ===================== // Task Tab Values (per-task data for custom modules) // ===================== export const taskTabValues = pgTable("task_tab_values", { id: serial("id").primaryKey(), taskId: integer("task_id").notNull().references(() => tasks.id, { onDelete: "cascade" }), tabId: integer("tab_id").notNull().references(() => formTabs.id, { onDelete: "cascade" }), values: jsonb("values").notNull().default({}), // stored user input per schema createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), }, (table) => ({ uniqueTaskTab: unique("task_tab_values_task_id_tab_id_key").on(table.taskId, table.tabId), taskIndex: index("task_tab_values_task_idx").on(table.taskId), tabIndex: index("task_tab_values_tab_idx").on(table.tabId), })); export const taskTabValuesRelations = relations(taskTabValues, ({ one }) => ({ task: one(tasks, { fields: [taskTabValues.taskId], references: [tasks.id], }), tab: one(formTabs, { fields: [taskTabValues.tabId], references: [formTabs.id], }), })); export const insertTaskTabValueSchema = createInsertSchema(taskTabValues).omit({ id: true, createdAt: true, updatedAt: true, }); export type TaskTabValue = typeof taskTabValues.$inferSelect; export type InsertTaskTabValue = z.infer; // ===================== // Device Tokens for Push Notifications // ===================== export const deviceTokens = pgTable("device_tokens", { id: serial("id").primaryKey(), userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), token: text("token").notNull(), platform: varchar("platform", { length: 20 }).notNull(), // 'android', 'ios', 'web' deviceInfo: jsonb("device_info"), // optional device metadata lastActiveAt: timestamp("last_active_at").defaultNow(), createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ uniqueUserToken: unique("device_tokens_user_id_token_key").on(table.userId, table.token), userIndex: index("device_tokens_user_idx").on(table.userId), tokenIndex: index("device_tokens_token_idx").on(table.token), orgIndex: index("device_tokens_org_idx").on(table.organizationId), })); export const deviceTokensRelations = relations(deviceTokens, ({ one }) => ({ user: one(users, { fields: [deviceTokens.userId], references: [users.id], }), })); export const insertDeviceTokenSchema = createInsertSchema(deviceTokens).omit({ id: true, createdAt: true, lastActiveAt: true, }); export type DeviceToken = typeof deviceTokens.$inferSelect; export type InsertDeviceToken = z.infer; // ===================== // User Online Status Tracking // ===================== export const userPresence = pgTable("user_presence", { id: serial("id").primaryKey(), userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }).unique("user_presence_user_id_key"), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), isOnline: boolean("is_online").default(false), lastSeenAt: timestamp("last_seen_at").defaultNow(), activeDevices: integer("active_devices").default(0), // Активный чат: используется сервером, чтобы не слать push, если пользователь // уже смотрит в этот чат на видимой вкладке. activeChatType: text("active_chat_type"), // 'messenger' | 'task' activeChatId: integer("active_chat_id"), activeChatVisible: boolean("active_chat_visible").default(false), activeChatUpdatedAt: timestamp("active_chat_updated_at").defaultNow(), }, (table) => ({ userIndex: index("user_presence_user_idx").on(table.userId), onlineIndex: index("user_presence_online_idx").on(table.isOnline), orgIndex: index("user_presence_org_idx").on(table.organizationId), })); export const userPresenceRelations = relations(userPresence, ({ one }) => ({ user: one(users, { fields: [userPresence.userId], references: [users.id], }), })); export const insertUserPresenceSchema = createInsertSchema(userPresence).omit({ id: true, }); export type UserPresence = typeof userPresence.$inferSelect; export type InsertUserPresence = z.infer; // ===================== // Web Push Subscriptions for PWA // ===================== export const webPushSubscriptions = pgTable("web_push_subscriptions", { id: serial("id").primaryKey(), userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), endpoint: text("endpoint").notNull().unique("web_push_subscriptions_endpoint_key"), subscription: text("subscription").notNull(), createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ userIndex: index("web_push_user_idx").on(table.userId), orgIndex: index("web_push_org_idx").on(table.organizationId), endpointIndex: index("web_push_endpoint_idx").on(table.endpoint), })); export const webPushSubscriptionsRelations = relations(webPushSubscriptions, ({ one }) => ({ user: one(users, { fields: [webPushSubscriptions.userId], references: [users.id], }), organization: one(organizations, { fields: [webPushSubscriptions.organizationId], references: [organizations.id], }), })); export type WebPushSubscription = typeof webPushSubscriptions.$inferSelect; // ===================== // Organization API Keys (for MCP server access) // ===================== // Скоупы прав доступа API-ключа. // NULL в БД = полный доступ (legacy-ключи, созданные до введения скоупов). export type ApiKeyScopes = { mode: 'read' | 'write' | 'full'; // read = только чтение; write = чтение + создание; full = всё formIds: number[] | null; // null = все формы tableIds: number[] | null; // null = все справочники }; export const organizationApiKeys = pgTable("organization_api_keys", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), keyHash: varchar("key_hash", { length: 64 }).notNull().unique("organization_api_keys_key_hash_key"), keyPrefix: varchar("key_prefix", { length: 16 }).notNull(), label: varchar("label", { length: 100 }).notNull().default("Default"), createdBy: integer("created_by").notNull().references(() => users.id), createdAt: timestamp("created_at").defaultNow(), lastUsedAt: timestamp("last_used_at"), isActive: boolean("is_active").notNull().default(true), isLegacy: boolean("is_legacy").notNull().default(false), scopes: jsonb("scopes").$type(), // Привязка ключа к боту (1:1); NULL — обычный/legacy-ключ без бота botId: integer("bot_id").references(() => bots.id), }, (table) => ({ orgIndex: index("api_keys_org_idx").on(table.organizationId), hashIndex: index("api_keys_hash_idx").on(table.keyHash), // Один ключ на бота (частичный индекс — NULL не ограничивается) botUniqueIdx: uniqueIndex("organization_api_keys_bot_id_unique").on(table.botId).where(sql`${table.botId} IS NOT NULL`), })); export type OrganizationApiKey = typeof organizationApiKeys.$inferSelect; export type InsertOrganizationApiKey = typeof organizationApiKeys.$inferInsert; // ===================== // Custom JS Pages (полноэкранные страницы через боковую панель) // ===================== export const customPages = pgTable("custom_pages", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), name: varchar("name", { length: 200 }).notNull(), slug: varchar("slug", { length: 100 }).notNull(), description: text("description"), code: text("code").notNull().default(''), formIds: integer("form_ids").array().default([]), icon: varchar("icon", { length: 50 }).default("FileCode"), isActive: boolean("is_active").default(true), showInSidebar: boolean("show_in_sidebar").default(false), hideHeader: boolean("hide_header").notNull().default(false), createdBy: integer("created_by").notNull().references(() => users.id), createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), }, (table) => ({ uniqueSlug: unique("custom_pages_org_slug_unique").on(table.organizationId, table.slug), orgIndex: index("custom_pages_org_idx").on(table.organizationId), })); export const customPagesRelations = relations(customPages, ({ one }) => ({ organization: one(organizations, { fields: [customPages.organizationId], references: [organizations.id], }), creator: one(users, { fields: [customPages.createdBy], references: [users.id], }), })); export const insertCustomPageSchema = createInsertSchema(customPages).omit({ id: true, createdAt: true, updatedAt: true, }); export type CustomPage = typeof customPages.$inferSelect; export type InsertCustomPage = z.infer; // ── Automations ────────────────────────────────────────────────────────────── export const automations = pgTable("automations", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), name: varchar("name", { length: 200 }).notNull(), description: text("description"), code: text("code").notNull().default(""), trigger: varchar("trigger", { length: 50 }).notNull().default("manual"), triggerConfig: jsonb("trigger_config").$type>(), isActive: boolean("is_active").default(true), runOffline: boolean("run_offline").default(false), // автоматизация может выполняться на клиенте без интернета clientCompatible: boolean("client_compatible").default(false), // код совместим с browser VM / Web Worker createdBy: integer("created_by").notNull().references(() => users.id), lastScheduledRunAt: timestamp("last_scheduled_run_at"), // последний запуск по расписанию (защита от дублей) createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), }, (table) => ({ orgIndex: index("automations_org_idx").on(table.organizationId), })); export const automationsRelations = relations(automations, ({ one }) => ({ organization: one(organizations, { fields: [automations.organizationId], references: [organizations.id], }), creator: one(users, { fields: [automations.createdBy], references: [users.id], }), })); export const insertAutomationSchema = createInsertSchema(automations).omit({ id: true, createdAt: true, updatedAt: true, }); export type Automation = typeof automations.$inferSelect; export type InsertAutomation = z.infer; // Task reminders table export const taskReminders = pgTable("task_reminders", { id: serial("id").primaryKey(), taskId: integer("task_id").notNull().references(() => tasks.id, { onDelete: "cascade" }), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), createdByUserId: integer("created_by_user_id").notNull().references(() => users.id), remindAt: timestamp("remind_at").notNull(), note: text("note"), // recipients: JSON array of { type: 'user', userId: number } | { type: 'role', role: string } | { type: 'orgRole', roleId: number } recipients: jsonb("recipients").notNull().$type>().default([]), isSent: boolean("is_sent").default(false), createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ // Композитный индекс для воркера напоминаний: ищет невысланные, отсортированные по времени remindAtIsSentIdx: index("task_reminders_remind_at_is_sent_idx").on(table.remindAt, table.isSent), })); export const insertTaskReminderSchema = createInsertSchema(taskReminders).omit({ id: true, isSent: true, createdAt: true, }).extend({ recipients: z.array(z.union([ z.object({ type: z.literal('user'), userId: z.number() }), z.object({ type: z.literal('role'), role: z.string() }), z.object({ type: z.literal('orgRole'), roleId: z.number() }), ])).optional(), }); export type TaskReminder = typeof taskReminders.$inferSelect; export type InsertTaskReminder = z.infer; export type ReminderRecipient = { type: 'user'; userId: number } | { type: 'role'; role: string } | { type: 'orgRole'; roleId: number }; // Отложенная смена статуса задачи (автоматизации: ctx.tasks.scheduleStatusChange). // Воркер server/workers/delayed-status-change.worker.ts раз в 5 секунд // подбирает просроченные строки и переводит задачу в target_status_id, // только если она всё ещё в expected_status_id (иначе — отмена). export const delayedStatusChanges = pgTable("delayed_status_changes", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), taskId: integer("task_id").notNull().references(() => tasks.id, { onDelete: "cascade" }), formId: integer("form_id").notNull(), expectedStatusId: integer("expected_status_id").notNull(), targetStatusId: integer("target_status_id").notNull(), runAt: timestamp("run_at").notNull(), executedAt: timestamp("executed_at"), createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ // Частичный индекс для воркера: ищет неисполненные, отсортированные по времени runAtIdx: index("delayed_status_changes_run_at_idx").on(table.runAt).where(sql`${table.executedAt} IS NULL`), })); export type DelayedStatusChange = typeof delayedStatusChanges.$inferSelect; export type InsertDelayedStatusChange = typeof delayedStatusChanges.$inferInsert; // Task audit log table (history of changes) export const taskAuditLog = pgTable("task_audit_log", { id: serial("id").primaryKey(), taskId: integer("task_id").notNull().references(() => tasks.id, { onDelete: "cascade" }), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), action: varchar("action", { length: 50 }).notNull(), // 'task.created', 'task.updated', 'status.changed', 'field.changed' fieldId: integer("field_id"), fieldName: text("field_name"), oldValue: jsonb("old_value"), newValue: jsonb("new_value"), changedBy: integer("changed_by").references(() => users.id), changedByName: text("changed_by_name"), // Атрибуция действий бота (действие выполнено по API-ключу бота); NULL — действие пользователя botId: integer("bot_id"), metadata: jsonb("metadata"), createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ // Быстрая выборка истории изменений задачи (убывающий createdAt для хронологии) taskCreatedIdx: index("task_audit_log_task_created_idx").on(table.taskId, table.createdAt.desc()), })); export type TaskAuditLog = typeof taskAuditLog.$inferSelect; export type InsertTaskAuditLog = typeof taskAuditLog.$inferInsert; // ===================== // Task Tables (inline editable tables inside task tabs) // ===================== export const taskTables = pgTable("task_tables", { id: serial("id").primaryKey(), taskId: integer("task_id").notNull().references(() => tasks.id, { onDelete: "cascade" }), tabId: integer("tab_id").references(() => formTabs.id, { onDelete: "cascade" }), // nullable — существующие данные могут не иметь tab_id name: varchar("name", { length: 200 }).notNull(), columns: jsonb("columns"), // column definitions (JSON) createdBy: integer("created_by").references(() => users.id), createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), }); export const taskTableCells = pgTable("task_table_cells", { id: serial("id").primaryKey(), tableId: integer("table_id").notNull().references(() => taskTables.id, { onDelete: "cascade" }), rowIndex: integer("row_index").notNull(), colIndex: integer("col_index").notNull(), value: text("value"), formula: text("formula"), updatedBy: integer("updated_by").references(() => users.id), updatedAt: timestamp("updated_at").defaultNow(), }, (table) => ({ uniqueCell: unique("task_table_cells_table_id_row_index_col_index_key").on(table.tableId, table.rowIndex, table.colIndex), })); export const taskTableCellHistory = pgTable("task_table_cell_history", { id: serial("id").primaryKey(), cellId: integer("cell_id").notNull().references(() => taskTableCells.id, { onDelete: "cascade" }), tableId: integer("table_id").notNull().references(() => taskTables.id, { onDelete: "cascade" }), rowIndex: integer("row_index").notNull(), colIndex: integer("col_index").notNull(), prevValue: text("prev_value"), prevFormula: text("prev_formula"), newValue: text("new_value"), newFormula: text("new_formula"), changedBy: integer("changed_by").references(() => users.id), changedAt: timestamp("changed_at").defaultNow(), }); // ===================== // System Audit Log // ===================== export const systemAuditLog = pgTable("system_audit_log", { id: serial("id").primaryKey(), action: varchar("action", { length: 100 }).notNull(), userId: integer("user_id").references(() => users.id, { onDelete: "set null" }), organizationId: integer("organization_id").references(() => organizations.id, { onDelete: "set null" }), taskId: integer("task_id"), details: jsonb("details"), ip: varchar("ip", { length: 45 }), userAgent: text("user_agent"), createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ actionCreatedIdx: index("system_audit_log_action_created_idx").on(table.action, table.createdAt.desc()), orgCreatedIdx: index("system_audit_log_org_created_idx").on(table.organizationId, table.createdAt.desc()), })); export type SystemAuditLog = typeof systemAuditLog.$inferSelect; export type InsertSystemAuditLog = typeof systemAuditLog.$inferInsert; // ===================== // Messenger: Conversations (direct & group chats) // ===================== export const conversations = pgTable("conversations", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), type: varchar("type", { length: 20 }).notNull().default("direct"), // 'direct' | 'group' | 'bot_direct' name: varchar("name", { length: 200 }), // only for group chats createdBy: integer("created_by").notNull().references(() => users.id), isDefault: boolean("is_default").default(false), // авто-создаваемая группа «Наша Фирма» botId: integer("bot_id"), // for bot_direct conversations createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), }, (table) => ({ orgIdx: index("conversations_org_idx").on(table.organizationId), })); export const conversationMembers = pgTable("conversation_members", { id: serial("id").primaryKey(), conversationId: integer("conversation_id").notNull().references(() => conversations.id, { onDelete: "cascade" }), userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), joinedAt: timestamp("joined_at").defaultNow(), lastReadAt: timestamp("last_read_at"), externalOrgId: integer("external_org_id"), reactionUnreadCount: integer("reaction_unread_count").notNull().default(0), mutedAt: timestamp("muted_at"), }, (table) => ({ uniqueMember: unique("conversation_members_conv_user_key").on(table.conversationId, table.userId), convIdx: index("conversation_members_conv_idx").on(table.conversationId), userIdx: index("conversation_members_user_idx").on(table.userId), })); export const conversationMessages = pgTable("conversation_messages", { id: serial("id").primaryKey(), conversationId: integer("conversation_id").notNull().references(() => conversations.id, { onDelete: "cascade" }), authorId: integer("author_id").references(() => users.id), // null for bot messages botId: integer("bot_id"), // set for bot messages in bot_direct conversations message: text("message").notNull().default(""), replyToId: integer("reply_to_id"), mentionedUserIds: jsonb("mentioned_user_ids").$type(), attachments: jsonb("attachments").$type>(), botButtons: jsonb("bot_buttons").$type>(), // Идемпотентность: клиентский ключ сообщения (uuid от клиента / id записи очереди). // Partial unique index — см. миграцию 0087. clientMessageId: varchar("client_message_id", { length: 64 }), createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), isDeleted: boolean("is_deleted").default(false), }, (table) => ({ convCreatedIdx: index("conv_messages_conv_created_idx").on(table.conversationId, table.createdAt), })); export type Conversation = typeof conversations.$inferSelect; export type InsertConversation = typeof conversations.$inferInsert; export type ConversationMember = typeof conversationMembers.$inferSelect; export type InsertConversationMember = typeof conversationMembers.$inferInsert; export type ConversationMessage = typeof conversationMessages.$inferSelect; export type InsertConversationMessage = typeof conversationMessages.$inferInsert; // ===================== // Message Reactions // ===================== export const messageReactions = pgTable("message_reactions", { id: serial("id").primaryKey(), taskMessageId: integer("task_message_id").references(() => taskMessages.id, { onDelete: "cascade" }), convMessageId: integer("conv_message_id").references(() => conversationMessages.id, { onDelete: "cascade" }), userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), emoji: varchar("emoji", { length: 10 }).notNull(), createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ uniqueTaskReaction: unique("mr_task_user_emoji_key").on(table.taskMessageId, table.userId, table.emoji), uniqueConvReaction: unique("mr_conv_user_emoji_key").on(table.convMessageId, table.userId, table.emoji), taskMsgIdx: index("mr_task_msg_idx").on(table.taskMessageId), convMsgIdx: index("mr_conv_msg_idx").on(table.convMessageId), })); export type MessageReaction = typeof messageReactions.$inferSelect; export type InsertMessageReaction = typeof messageReactions.$inferInsert; // ===================== // Polls // ===================== export const polls = pgTable("polls", { id: serial("id").primaryKey(), taskMessageId: integer("task_message_id").references(() => taskMessages.id, { onDelete: "cascade" }), convMessageId: integer("conv_message_id").references(() => conversationMessages.id, { onDelete: "cascade" }), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), question: text("question").notNull(), options: jsonb("options").$type().notNull().default([]), isMultiple: boolean("is_multiple").default(false), isClosed: boolean("is_closed").default(false), createdBy: integer("created_by").notNull().references(() => users.id), createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ orgIdx: index("polls_org_idx").on(table.organizationId), })); export type Poll = typeof polls.$inferSelect; export type InsertPoll = typeof polls.$inferInsert; // ===================== // Poll Votes // ===================== export const pollVotes = pgTable("poll_votes", { id: serial("id").primaryKey(), pollId: integer("poll_id").notNull().references(() => polls.id, { onDelete: "cascade" }), userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), optionIndexes: integer("option_indexes").array().notNull().default([]), createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), }, (table) => ({ uniqueVote: unique("poll_votes_poll_user_key").on(table.pollId, table.userId), pollIdx: index("poll_votes_poll_idx").on(table.pollId), })); export type PollVote = typeof pollVotes.$inferSelect; export type InsertPollVote = typeof pollVotes.$inferInsert; // ===================== // Org Connections (Cross-Organization Chat) // ===================== export const orgConnections = pgTable("org_connections", { id: serial("id").primaryKey(), initiatorOrgId: integer("initiator_org_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), partnerOrgId: integer("partner_org_id").references(() => organizations.id, { onDelete: "cascade" }), status: varchar("status", { length: 20 }).notNull().default("pending"), // pending | active | revoked token: varchar("token", { length: 64 }).notNull().unique(), createdAt: timestamp("created_at").defaultNow(), acceptedAt: timestamp("accepted_at"), }, (table) => ({ initiatorIdx: index("org_connections_initiator_idx").on(table.initiatorOrgId), partnerIdx: index("org_connections_partner_idx").on(table.partnerOrgId), tokenIdx: index("org_connections_token_idx").on(table.token), })); export type OrgConnection = typeof orgConnections.$inferSelect; export type InsertOrgConnection = typeof orgConnections.$inferInsert; // ===================== // Roles // ===================== export const roles = pgTable("roles", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), name: varchar("name", { length: 100 }).notNull(), description: text("description"), parentId: integer("parent_id"), managerId: integer("manager_id").references(() => users.id, { onDelete: "set null" }), // Флаги доступа к модулям для членов роли (матрица на /users) gpsAccess: boolean("gps_access").notNull().default(true), financeAccess: boolean("finance_access").notNull().default(true), createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ orgIdx: index("roles_org_idx").on(table.organizationId), parentIdx: index("roles_parent_idx").on(table.parentId), })); export type Role = typeof roles.$inferSelect; export type InsertRole = typeof roles.$inferInsert; // ===================== // Role Members // ===================== export const roleMembers = pgTable("role_members", { id: serial("id").primaryKey(), roleId: integer("role_id").notNull().references(() => roles.id, { onDelete: "cascade" }), userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ uniqueMember: unique("role_members_role_user_key").on(table.roleId, table.userId), roleIdx: index("role_members_role_idx").on(table.roleId), userIdx: index("role_members_user_idx").on(table.userId), })); // ===================== // Application Roles (роли доступа в приложении: admin, user, accountant...) // ===================== export const appRoles = pgTable("app_roles", { id: serial("id").primaryKey(), slug: varchar("slug", { length: 50 }).notNull().unique(), name: varchar("name", { length: 100 }).notNull(), description: text("description"), isSystem: boolean("is_system").default(false), createdAt: timestamp("created_at").defaultNow(), }); export type AppRole = typeof appRoles.$inferSelect; export type InsertAppRole = typeof appRoles.$inferInsert; // ===================== // Permissions (атомарные права) // ===================== export const permissions = pgTable("permissions", { id: serial("id").primaryKey(), code: varchar("code", { length: 100 }).notNull().unique(), name: varchar("name", { length: 100 }).notNull(), description: text("description"), module: varchar("module", { length: 50 }), createdAt: timestamp("created_at").defaultNow(), }); export type Permission = typeof permissions.$inferSelect; export type InsertPermission = typeof permissions.$inferInsert; // ===================== // Application Role Permissions (matrix) // ===================== export const appRolePermissions = pgTable("app_role_permissions", { id: serial("id").primaryKey(), appRoleId: integer("app_role_id").notNull().references(() => appRoles.id, { onDelete: "cascade" }), permissionId: integer("permission_id").notNull().references(() => permissions.id, { onDelete: "cascade" }), }, (table) => ({ uniqueRolePermission: unique("app_role_permissions_role_perm_key").on(table.appRoleId, table.permissionId), })); export type AppRolePermission = typeof appRolePermissions.$inferSelect; export type InsertAppRolePermission = typeof appRolePermissions.$inferInsert; // ===================== // Task Roles (роли, назначенные на задачу) // ===================== export const taskRoles = pgTable("task_roles", { id: serial("id").primaryKey(), taskId: integer("task_id").notNull().references(() => tasks.id, { onDelete: "cascade" }), roleId: integer("role_id").notNull().references(() => roles.id, { onDelete: "cascade" }), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ uniqueTaskRole: unique("task_roles_task_role_key").on(table.taskId, table.roleId), taskIdx: index("task_roles_task_idx").on(table.taskId), roleIdx: index("task_roles_role_idx").on(table.roleId), orgIdx: index("task_roles_org_idx").on(table.organizationId), })); export type TaskRole = typeof taskRoles.$inferSelect; export type InsertTaskRole = typeof taskRoles.$inferInsert; // ===================== // Task User Access (доступ пользователей к задаче) // ===================== export const taskUserAccess = pgTable("task_user_access", { id: serial("id").primaryKey(), taskId: integer("task_id").notNull().references(() => tasks.id, { onDelete: "cascade" }), userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), grantedVia: varchar("granted_via", { length: 20 }).notNull().default("direct"), roleId: integer("role_id"), statusId: integer("status_id").references(() => formStatuses.id, { onDelete: "set null" }), firstInteractionAt: timestamp("first_interaction_at").defaultNow(), revokedAt: timestamp("revoked_at"), }, (table) => ({ uniqueTaskUser: unique("task_user_access_task_user_key").on(table.taskId, table.userId), taskIdx: index("task_user_access_task_idx").on(table.taskId), userIdx: index("task_user_access_user_idx").on(table.userId), orgIdx: index("task_user_access_org_idx").on(table.organizationId), })); export type TaskUserAccess = typeof taskUserAccess.$inferSelect; export type InsertTaskUserAccess = typeof taskUserAccess.$inferInsert; // ===================== // Task Assignees (несколько ответственных) // ===================== export const taskAssignees = pgTable("task_assignees", { id: serial("id").primaryKey(), taskId: integer("task_id").notNull().references(() => tasks.id, { onDelete: "cascade" }), userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), // Замещение: кого замещает этот исполнитель (NULL — прямое назначение) delegatedFromUserId: integer("delegated_from_user_id").references(() => users.id, { onDelete: "cascade" }), // Отсутствующий исполнитель возвращён в задачу «на ознакомление» после перехода pendingReview: boolean("pending_review").notNull().default(false), createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ uniqueTaskAssignee: unique("task_assignees_task_user_key").on(table.taskId, table.userId), taskIdx: index("task_assignees_task_idx").on(table.taskId), userIdx: index("task_assignees_user_idx").on(table.userId), orgIdx: index("task_assignees_org_idx").on(table.organizationId), })); export type TaskAssignee = typeof taskAssignees.$inferSelect; export type InsertTaskAssignee = typeof taskAssignees.$inferInsert; // ===================== // Task Inbox Hidden (пользователь скрыл задачу из inbox) // ===================== export const taskInboxHidden = pgTable("task_inbox_hidden", { id: serial("id").primaryKey(), taskId: integer("task_id").notNull().references(() => tasks.id, { onDelete: "cascade" }), userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ uniqueTaskUser: unique("task_inbox_hidden_task_user_key").on(table.taskId, table.userId), taskIdx: index("task_inbox_hidden_task_idx").on(table.taskId), userIdx: index("task_inbox_hidden_user_idx").on(table.userId), orgIdx: index("task_inbox_hidden_org_idx").on(table.organizationId), })); export type TaskInboxHidden = typeof taskInboxHidden.$inferSelect; export type InsertTaskInboxHidden = typeof taskInboxHidden.$inferInsert; // ===================== // Task Views (последние просмотренные задачи пользователем) // ===================== export const taskViews = pgTable("task_views", { id: serial("id").primaryKey(), taskId: integer("task_id").notNull().references(() => tasks.id, { onDelete: "cascade" }), userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), viewedAt: timestamp("viewed_at").defaultNow(), }, (table) => ({ uniqueTaskUser: unique("task_views_task_user_key").on(table.organizationId, table.userId, table.taskId), userViewedAtIdx: index("task_views_user_viewed_at_idx").on(table.organizationId, table.userId, table.viewedAt.desc()), taskIdx: index("task_views_task_idx").on(table.taskId), })); export type TaskView = typeof taskViews.$inferSelect; export type InsertTaskView = typeof taskViews.$inferInsert; // ===================== // RAG Embeddings (векторная память для AI-агентов) // ===================== export const ragEmbeddings = pgTable("rag_embeddings", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), entityType: varchar("entity_type", { length: 20 }).notNull(), // 'form' | 'task' | 'task_message' entityId: integer("entity_id").notNull(), content: text("content").notNull(), // Dimension is managed at startup via runStartupDataPatches — actual column type // is auto-migrated to match the configured embedding model (see server/index.ts). // Using vector(1) here as a placeholder; drizzle-kit is not used for this column. embedding: vector(1)("embedding"), metadata: jsonb("metadata").default({}), updatedAt: timestamp("updated_at").defaultNow(), }, (table) => ({ uniqueOrgEntityIdx: unique("rag_embeddings_org_entity_type_id_key").on(table.organizationId, table.entityType, table.entityId), orgTypeIdx: index("rag_embeddings_org_type_idx").on(table.organizationId, table.entityType), })); export type RagEmbedding = typeof ragEmbeddings.$inferSelect; export type InsertRagEmbedding = typeof ragEmbeddings.$inferInsert; // ===================== // Embedding Queue (отложенная генерация эмбеддингов для RAG) // ===================== export const embeddingQueue = pgTable("embedding_queue", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), entityType: varchar("entity_type", { length: 20 }).notNull(), // 'form' | 'task' | 'task_message' entityId: integer("entity_id").notNull(), operation: varchar("operation", { length: 20 }).notNull().default("upsert"), // 'upsert' | 'delete' createdAt: timestamp("created_at").defaultNow(), processedAt: timestamp("processed_at"), }, (table) => ({ // Only one pending operation per entity pendingEntityIdx: uniqueIndex("embedding_queue_pending_entity_idx").on(table.organizationId, table.entityType, table.entityId).where(sql`${table.processedAt} IS NULL`), pendingIdx: index("embedding_queue_processed_at_idx").on(table.processedAt).where(sql`${table.processedAt} IS NULL`), createdAtIdx: index("embedding_queue_created_at_idx").on(table.createdAt), })); export type EmbeddingQueue = typeof embeddingQueue.$inferSelect; export type InsertEmbeddingQueue = typeof embeddingQueue.$inferInsert; // ===================== // RAG Settings (настройки LLM/эмбеддингов на уровне организации) // ===================== export const ragSettings = pgTable("rag_settings", { organizationId: integer("organization_id").primaryKey().references(() => organizations.id, { onDelete: "cascade" }), provider: varchar("provider", { length: 50 }).notNull().default("openai"), // 'openai' | 'openai_compatible' | 'ollama' apiKey: text("api_key"), // зашифрованный API ключ baseUrl: text("base_url"), // базовый URL для кастомных провайдеров и Ollama embeddingModel: varchar("embedding_model", { length: 200 }).default("text-embedding-3-small"), chatModel: varchar("chat_model", { length: 200 }), // модель для суммаризации (Ollama: llama3, mistral; OpenAI: gpt-4o-mini) maxChunkChars: integer("max_chunk_chars"), // максимальный размер чанка в символах (null = по умолчанию для провайдера) summarizationEnabled: boolean("summarization_enabled").default(true), // включить/отключить авто-суммаризацию форм // Ссылки на реестр LLM-провайдеров (если заданы — приоритет над legacy-полями) embeddingProviderId: integer("embedding_provider_id").references(() => llmProviders.id, { onDelete: "set null" }), chatProviderId: integer("chat_provider_id").references(() => llmProviders.id, { onDelete: "set null" }), updatedAt: timestamp("updated_at").defaultNow(), }); export type RagSetting = typeof ragSettings.$inferSelect; export type InsertRagSetting = typeof ragSettings.$inferInsert; export const insertRagSettingSchema = createInsertSchema(ragSettings).omit({ updatedAt: true }); export const updateRagSettingSchema = insertRagSettingSchema.omit({ organizationId: true }).partial(); // ── LLM Providers registry ───────────────────────────────────────────────────── export const llmProviders = pgTable("llm_providers", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), name: varchar("name", { length: 200 }).notNull(), providerType: varchar("provider_type", { length: 50 }).notNull().default("openai"), // 'openai' | 'openai_compatible' | 'ollama' baseUrl: text("base_url"), // обязателен для openai_compatible и ollama apiKey: text("api_key"), // зашифрованный ключ enabledModels: jsonb("enabled_models").$type().default([]), // список разрешённых моделей customHeaders: jsonb("custom_headers").$type>().default({}), // кастомные HTTP-заголовки (например, {"User-Agent": "opencode/1.0"}) isActive: boolean("is_active").default(true), createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), }); export type LlmProvider = typeof llmProviders.$inferSelect; export type InsertLlmProvider = typeof llmProviders.$inferInsert; export const insertLlmProviderSchema = createInsertSchema(llmProviders).omit({ id: true, createdAt: true, updatedAt: true }); export const updateLlmProviderSchema = insertLlmProviderSchema.omit({ organizationId: true }).partial(); // ===================== // System Config (server-side key-value store) // ===================== export const systemConfig = pgTable("system_config", { key: varchar("key", { length: 255 }).primaryKey(), value: text("value").notNull(), updatedAt: timestamp("updated_at").defaultNow(), }); export type SystemConfig = typeof systemConfig.$inferSelect; // ===================== // File Uploads (tracking for auth checks on /uploads and /api/files/:key) // ===================== export const fileUploads = pgTable("file_uploads", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), uploadedBy: integer("uploaded_by").notNull().references(() => users.id, { onDelete: "cascade" }), fileKey: varchar("file_key", { length: 500 }).notNull().unique("file_uploads_file_key_key"), originalName: varchar("original_name", { length: 500 }), sizeBytes: integer("size_bytes"), taskId: integer("task_id"), fieldId: integer("field_id"), // Атрибуция загрузки бота (файл загружен по API-ключу бота); NULL — загрузка пользователя botId: integer("bot_id"), createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ orgIdx: index("file_uploads_org_idx").on(table.organizationId), keyIdx: index("file_uploads_key_idx").on(table.fileKey), })); export type FileUpload = typeof fileUploads.$inferSelect; export type InsertFileUpload = typeof fileUploads.$inferInsert; // ===================== // Contract Number Counters (сквозная нумерация договоров по форме/префиксу/году) // ===================== export const contractNumberCounters = pgTable("contract_number_counters", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), formId: integer("form_id").notNull().references(() => forms.id, { onDelete: "cascade" }), prefix: text("prefix").notNull(), year: integer("year").notNull(), lastNumber: integer("last_number").notNull().default(0), createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), }, (table) => ({ uniqueCounter: uniqueIndex("contract_number_counters_unique").on(table.organizationId, table.formId, table.prefix, table.year), })); export type ContractNumberCounter = typeof contractNumberCounters.$inferSelect; export type InsertContractNumberCounter = typeof contractNumberCounters.$inferInsert; // ===================== // Document Center Module // ===================== export const documentTemplateCategories = pgTable("document_template_categories", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), name: varchar("name", { length: 100 }).notNull(), icon: varchar("icon", { length: 50 }).default("file-text"), color: varchar("color", { length: 7 }).default("#1976d2"), sortOrder: integer("sort_order").default(0), createdAt: timestamp("created_at").defaultNow(), }); export type DocumentTemplateCategory = typeof documentTemplateCategories.$inferSelect; export type InsertDocumentTemplateCategory = typeof documentTemplateCategories.$inferInsert; export const documentTemplateFolders = pgTable("document_template_folders", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), parentId: integer("parent_id"), name: varchar("name", { length: 100 }).notNull(), position: integer("position").notNull().default(0), createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), }, (table) => ({ orgParentIdx: index("document_template_folders_org_parent_idx").on(table.organizationId, table.parentId), positionIdx: index("document_template_folders_position_idx").on(table.position), })); export type DocumentTemplateFolder = typeof documentTemplateFolders.$inferSelect; export type InsertDocumentTemplateFolder = typeof documentTemplateFolders.$inferInsert; export const documentFolderMappings = pgTable("document_folder_mappings", { id: serial("id").primaryKey(), folderId: integer("folder_id").notNull().references(() => documentTemplateFolders.id, { onDelete: "cascade" }), code: varchar("code", { length: 100 }).notNull(), label: varchar("label", { length: 200 }), description: text("description"), source: varchar("source", { length: 30 }).notNull(), mapping: jsonb("mapping").$type<{ fieldCode?: string; formId?: number; systemKey?: string; staticValue?: string; userId?: number; relatedTaskId?: number; tabId?: number; assetType?: string; assetId?: number; selectionMode?: string; taskFieldCode?: string; // related_task_field support relationType?: 'task_field' | 'parent' | 'child' | 'contract_legal_entity'; sourceFormId?: number; sourceFieldCode?: string; targetFormId?: number; targetFieldCode?: string; transform?: { type: string; params?: Record } | string; locale?: string; }>().default({}), defaultValue: text("default_value"), sortOrder: integer("sort_order").default(0), createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ uniqueCodePerFolder: unique("document_folder_mappings_code_unique").on(table.folderId, table.code), })); export type DocumentFolderMapping = typeof documentFolderMappings.$inferSelect; export type InsertDocumentFolderMapping = typeof documentFolderMappings.$inferInsert; export const documentTemplates = pgTable("document_templates", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), categoryId: integer("category_id").references(() => documentTemplateCategories.id, { onDelete: "set null" }), folderId: integer("folder_id").references(() => documentTemplateFolders.id, { onDelete: "set null" }), formId: integer("form_id").references(() => forms.id, { onDelete: "set null" }), name: varchar("name", { length: 200 }).notNull(), description: text("description"), content: text("content").notNull(), pageOrientation: varchar("page_orientation", { length: 10 }).default("portrait"), pageSize: varchar("page_size", { length: 10 }).default("A4"), margins: jsonb("margins").$type<{ top: number; right: number; bottom: number; left: number }>().default({ top: 1440, right: 1440, bottom: 1440, left: 1440 }), assetPlacements: jsonb("asset_placements").$type>().default([]), sourceDocxFileKey: varchar("source_docx_file_key", { length: 500 }), // Target file field where generated document should be saved targetFormId: integer("target_form_id").references(() => forms.id, { onDelete: "set null" }), targetFieldCode: varchar("target_field_code", { length: 100 }), targetTaskSource: varchar("target_task_source", { length: 20 }).default("current"), targetSourceFieldCode: varchar("target_source_field_code", { length: 100 }), status: varchar("status", { length: 20 }).notNull().default("draft"), createdBy: integer("created_by").notNull().references(() => users.id), createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), }, (table) => ({ orgStatusIdx: index("document_templates_org_status_idx").on(table.organizationId, table.status), orgCategoryIdx: index("document_templates_org_category_idx").on(table.organizationId, table.categoryId), orgFolderIdx: index("document_templates_org_folder_idx").on(table.organizationId, table.folderId), })); export type DocumentTemplate = typeof documentTemplates.$inferSelect; export type InsertDocumentTemplate = typeof documentTemplates.$inferInsert; export const templateVariables = pgTable("template_variables", { id: serial("id").primaryKey(), templateId: integer("template_id").notNull().references(() => documentTemplates.id, { onDelete: "cascade" }), code: varchar("code", { length: 100 }).notNull(), label: varchar("label", { length: 200 }), description: text("description"), source: varchar("source", { length: 30 }).notNull(), mapping: jsonb("mapping").$type<{ fieldCode?: string; formId?: number; systemKey?: string; staticValue?: string; userId?: number; relatedTaskId?: number; tabId?: number; assetType?: string; assetId?: number; selectionMode?: string; taskFieldCode?: string; // related_task_field support relationType?: 'task_field' | 'parent' | 'child' | 'contract_legal_entity'; sourceFormId?: number; sourceFieldCode?: string; targetFormId?: number; targetFieldCode?: string; transform?: { type: string; params?: Record } | string; locale?: string; }>().default({}), defaultValue: text("default_value"), sortOrder: integer("sort_order").default(0), createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ uniqueCodePerTemplate: unique("template_variables_code_unique").on(table.templateId, table.code), })); export type TemplateVariable = typeof templateVariables.$inferSelect; export type InsertTemplateVariable = typeof templateVariables.$inferInsert; export const organizationAssets = pgTable("organization_assets", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), type: varchar("type", { length: 20 }).notNull(), label: varchar("label", { length: 200 }).notNull(), fileKey: varchar("file_key", { length: 500 }).notNull(), userId: integer("user_id").references(() => users.id, { onDelete: "set null" }), position: varchar("position", { length: 200 }), formId: integer("form_id").references(() => forms.id, { onDelete: "set null" }), fieldCode: varchar("field_code", { length: 100 }), fieldValue: text("field_value"), settings: jsonb("settings").$type<{ width?: number; height?: number; opacity?: number; }>().default({}), isActive: boolean("is_active").default(true), createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ orgTypeIdx: index("organization_assets_org_type_idx").on(table.organizationId, table.type), orgFormFieldIdx: index("organization_assets_form_field_idx").on(table.formId, table.fieldCode, table.fieldValue), })); export type OrganizationAsset = typeof organizationAssets.$inferSelect; export type InsertOrganizationAsset = typeof organizationAssets.$inferInsert; export const documentGenerations = pgTable("document_generations", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), templateId: integer("template_id").notNull().references(() => documentTemplates.id, { onDelete: "cascade" }), taskId: integer("task_id").references(() => tasks.id, { onDelete: "set null" }), generatedBy: integer("generated_by").references(() => users.id, { onDelete: "set null" }), templateSnapshot: text("template_snapshot").notNull(), substitutedData: jsonb("substituted_data").$type>().notNull().default({}), unresolvedVariables: text("unresolved_variables").array(), pdfFileKey: varchar("pdf_file_key", { length: 500 }), docxFileKey: varchar("docx_file_key", { length: 500 }), status: varchar("status", { length: 20 }).notNull().default("pending"), errorMessage: text("error_message"), generationDurationMs: integer("generation_duration_ms"), signatureStatus: varchar("signature_status", { length: 20 }).default("unsigned"), createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ orgStatusIdx: index("document_generations_org_status_idx").on(table.organizationId, table.status), templateIdx: index("document_generations_template_idx").on(table.templateId), taskIdx: index("document_generations_task_idx").on(table.taskId), })); export type DocumentGeneration = typeof documentGenerations.$inferSelect; export type InsertDocumentGeneration = typeof documentGenerations.$inferInsert; export const documentPackages = pgTable("document_packages", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), name: varchar("name", { length: 200 }).notNull(), description: text("description"), category: varchar("category", { length: 50 }).default("general"), isActive: boolean("is_active").default(true), createdAt: timestamp("created_at").defaultNow(), }); export type DocumentPackage = typeof documentPackages.$inferSelect; export type InsertDocumentPackage = typeof documentPackages.$inferInsert; export const documentPackageItems = pgTable("document_package_items", { id: serial("id").primaryKey(), packageId: integer("package_id").notNull().references(() => documentPackages.id, { onDelete: "cascade" }), templateId: integer("template_id").notNull().references(() => documentTemplates.id), sortOrder: integer("sort_order").default(0), customName: varchar("custom_name", { length: 200 }), condition: jsonb("condition").$type<{ fieldCode?: string; operator?: 'eq' | 'ne' | 'exists' | 'not_empty' | 'gt' | 'lt'; value?: string; }>(), }); export type DocumentPackageItem = typeof documentPackageItems.$inferSelect; export type InsertDocumentPackageItem = typeof documentPackageItems.$inferInsert; export const documentSignatures = pgTable("document_signatures", { id: serial("id").primaryKey(), documentGenerationId: integer("document_generation_id").notNull().references(() => documentGenerations.id, { onDelete: "cascade" }), type: varchar("type", { length: 20 }).notNull().default("manual"), status: varchar("status", { length: 20 }).notNull().default("pending"), signedBy: integer("signed_by").references(() => users.id, { onDelete: "set null" }), signedAt: timestamp("signed_at"), comment: text("comment"), isCurrent: boolean("is_current").default(true), createdAt: timestamp("created_at").defaultNow(), }); export type DocumentSignature = typeof documentSignatures.$inferSelect; export type InsertDocumentSignature = typeof documentSignatures.$inferInsert; // ===================== // Finance Module Types // ===================== export interface IncomeRow { id: string; data: string | null; summa: number | null; stavkands: string | null; dogovorkontragenta: string | null; zakazchik: string | null; dogovorzakazchika: string | null; otvetstvenniipokontraktu: string | null; statyadop: string | null; kommentarii: string | null; statya: string | null; dochka: string | null; vnuchka: string | null; naznachenieplatezha: string | null; schetnaoplatu: string | null; overrideCategoryId?: string; overrideCategoryName?: string; } export interface ExpenseRow { id: string; data: string | null; summadokumenta: number | null; stavkands: string | null; dogovorkontragenta: string | null; zakazchik: string | null; dogovorzakazchika: string | null; otvetstvenniipokontraktu: string | null; statyadop: string | null; kommentarii: string | null; statya: string | null; dochka: string | null; vnuchka: string | null; naznachenieplatezha: string | null; ssilkanomervhodyaschegodokumenta: string | null; kontragent: string | null; overrideCategoryId?: string; overrideCategoryName?: string; } export interface PaginatedResult { rows: T[]; total: number; page: number; pageSize: number; totalPages: number; totalSum?: number; } export type RuleOperator = | "equals" | "not_equals" | "contains" | "greater_than" | "less_than" | "equal_number"; export interface CategoryRule { column: string; operator: RuleOperator; value: string; } export interface IncomeCategory { id: string; name: string; rules: CategoryRule[]; ruleLogic?: "and" | "or"; excluded?: boolean; children?: IncomeCategory[]; autoDetected?: boolean; treeId?: string; } export interface ExpenseCategory { id: string; name: string; rules: CategoryRule[]; ruleLogic?: "and" | "or"; excluded?: boolean; children?: ExpenseCategory[]; autoDetected?: boolean; treeId?: string; } export interface TableConfig { incomeTable: string; expenseTable: string; incomeSumColumn: string; expenseSumColumn: string; columnLabels: Record; } export const INCOME_FILTERABLE_COLUMNS = [ { key: "data" }, { key: "summa" }, { key: "statya" }, { key: "zakazchik" }, { key: "dochka" }, { key: "vnuchka" }, { key: "otvetstvenniipokontraktu" }, { key: "stavkands" }, { key: "kommentarii" }, { key: "dogovorkontragenta" }, { key: "schetnaoplatu" }, { key: "naznachenieplatezha" }, { key: "dogovorzakazchika" }, { key: "statyadop" }, { key: "nomervhodyaschegodokumenta" }, ]; export const DEFAULT_VISIBLE_COLUMNS = [ "data", "summa", "statya", "zakazchik", "dochka", "vnuchka", "kommentarii", ]; export const EXPENSE_FILTERABLE_COLUMNS = [ { key: "data" }, { key: "summadokumenta" }, { key: "statya" }, { key: "zakazchik" }, { key: "dochka" }, { key: "vnuchka" }, { key: "kommentarii" }, { key: "stavkands" }, { key: "naznachenieplatezha" }, { key: "otvetstvenniipokontraktu" }, { key: "dogovorkontragenta" }, { key: "dogovorzakazchika" }, { key: "statyadop" }, { key: "ssilkanomervhodyaschegodokumenta" }, { key: "kontragent" }, ]; export const DEFAULT_VISIBLE_EXPENSE_COLUMNS = [ "data", "summadokumenta", "statya", "zakazchik", "dochka", "vnuchka", "kommentarii", ]; export const NUMERIC_OPERATORS = [ { value: "equal_number" as RuleOperator, label: "равно (=)" }, { value: "greater_than" as RuleOperator, label: "больше (>)" }, { value: "less_than" as RuleOperator, label: "меньше (<)" }, ]; export const TEXT_OPERATORS = [ { value: "equals" as RuleOperator, label: "равно (=)" }, { value: "not_equals" as RuleOperator, label: "не равно (≠)" }, { value: "contains" as RuleOperator, label: "содержит (∋)" }, ]; export const MAX_CATEGORY_DEPTH = 5; export interface Tag { id: string; name: string; color: string; } export interface AutoDetectTree { id: string; name: string; columns: string[]; autoRefresh: boolean; isPrimary: boolean; mode: "split" | "merge"; filters: Array<{ column: string; operator: RuleOperator; value: string }>; filterLogic: "and" | "or"; } export interface AutoDetectConfig { trees: AutoDetectTree[]; } // ===================== // Idempotency Keys (защита от дублей при тяжёлых batch-операциях) // ===================== export const idempotencyKeys = pgTable("idempotency_keys", { id: serial("id").primaryKey(), key: varchar("key", { length: 255 }).notNull(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), endpoint: varchar("endpoint", { length: 255 }).notNull(), payloadHash: varchar("payload_hash", { length: 64 }).notNull(), responseJson: jsonb("response_json").notNull(), createdAt: timestamp("created_at").defaultNow(), expiresAt: timestamp("expires_at").notNull(), }, (table) => ({ orgKeyIdx: uniqueIndex("idempotency_keys_org_key_idx").on(table.organizationId, table.key), expiresAtIdx: index("idempotency_keys_expires_at_idx").on(table.expiresAt), })); export type IdempotencyKey = typeof idempotencyKeys.$inferSelect; export type InsertIdempotencyKey = typeof idempotencyKeys.$inferInsert; // ===================== // Error Logs (серверный журнал ошибок API, просмотр админом в Настройках) // ===================== export const errorLogs = pgTable("error_logs", { id: serial("id").primaryKey(), organizationId: integer("organization_id").references(() => organizations.id, { onDelete: "cascade" }), userId: integer("user_id"), method: varchar("method", { length: 10 }).notNull(), path: text("path").notNull(), statusCode: integer("status_code").notNull(), message: text("message"), createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ orgCreatedIdx: index("error_logs_org_created_idx").on(table.organizationId, table.createdAt), statusIdx: index("error_logs_status_idx").on(table.statusCode), })); export type ErrorLog = typeof errorLogs.$inferSelect; export type InsertErrorLog = typeof errorLogs.$inferInsert; export const FINANCE_COLORS = [ "#2563eb", "#e11d48", "#16a34a", "#f59e0b", "#8b5cf6", "#06b6d4", "#ea580c", "#84cc16", "#ec4899", "#14b8a6", "#a855f7", "#eab308", "#6366f1", "#f43f5e", "#10b981", "#d946ef", "#0ea5e9", "#f97316", "#22c55e", "#e879f9", ]; // ===================== // MedSchedule — семейное медицинское расписание // ===================== export const medFamilyGroups = pgTable("med_family_groups", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), name: text("name").notNull(), inviteCode: text("invite_code").unique().notNull(), createdBy: integer("created_by").notNull().references(() => users.id, { onDelete: "cascade" }), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }, (table) => ({ orgIdx: index("med_family_groups_org_idx").on(table.organizationId), inviteIdx: uniqueIndex("med_family_groups_invite_idx").on(table.inviteCode), })); export type MedFamilyGroup = typeof medFamilyGroups.$inferSelect; export type InsertMedFamilyGroup = typeof medFamilyGroups.$inferInsert; export const medFamilyMembers = pgTable("med_family_members", { id: serial("id").primaryKey(), familyId: integer("family_id").notNull().references(() => medFamilyGroups.id, { onDelete: "cascade" }), userId: integer("user_id").references(() => users.id, { onDelete: "cascade" }), profileName: text("profile_name").notNull(), profileType: text("profile_type").notNull().default("person"), dateOfBirth: date("date_of_birth"), gender: text("gender"), notes: text("notes"), telegramChatId: text("telegram_chat_id"), maxChatId: text("max_chat_id"), role: text("role").notNull().default("member"), createdBy: integer("created_by").notNull().references(() => users.id, { onDelete: "cascade" }), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }, (table) => ({ familyIdx: index("med_family_members_family_idx").on(table.familyId), userIdx: index("med_family_members_user_idx").on(table.userId), })); export type MedFamilyMember = typeof medFamilyMembers.$inferSelect; export type InsertMedFamilyMember = typeof medFamilyMembers.$inferInsert; export const medMedications = pgTable("med_medications", { id: serial("id").primaryKey(), familyId: integer("family_id").notNull().references(() => medFamilyGroups.id, { onDelete: "cascade" }), memberId: integer("member_id").notNull().references(() => medFamilyMembers.id, { onDelete: "cascade" }), name: text("name").notNull(), dosage: text("dosage"), form: text("form"), schedule: jsonb("schedule").$type<{ type: "daily" | "weekly" | "interval" | "as_needed"; times: string[]; days?: number[]; intervalHours?: number; startDate: string; endDate?: string; }>().notNull(), instructions: text("instructions"), color: text("color").default("#4CAF50"), isActive: boolean("is_active").default(true), createdBy: integer("created_by").notNull().references(() => users.id, { onDelete: "cascade" }), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }, (table) => ({ familyIdx: index("med_medications_family_idx").on(table.familyId), memberIdx: index("med_medications_member_idx").on(table.memberId), })); export type MedMedication = typeof medMedications.$inferSelect; export type InsertMedMedication = typeof medMedications.$inferInsert; export const medIntakeLogs = pgTable("med_intake_logs", { id: serial("id").primaryKey(), medicationId: integer("medication_id").notNull().references(() => medMedications.id, { onDelete: "cascade" }), memberId: integer("member_id").notNull().references(() => medFamilyMembers.id, { onDelete: "cascade" }), scheduledAt: timestamp("scheduled_at").notNull(), takenAt: timestamp("taken_at"), status: text("status").$type<"pending" | "taken" | "skipped" | "late" | "snoozed">().default("pending").notNull(), note: text("note"), remindedVia: text("reminded_via").array().default(sql`'{}'::text[]`), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }, (table) => ({ medicationIdx: index("med_intake_logs_medication_idx").on(table.medicationId), memberIdx: index("med_intake_logs_member_idx").on(table.memberId), scheduledIdx: index("med_intake_logs_scheduled_idx").on(table.scheduledAt), statusIdx: index("med_intake_logs_status_idx").on(table.status), })); export type MedIntakeLog = typeof medIntakeLogs.$inferSelect; export type InsertMedIntakeLog = typeof medIntakeLogs.$inferInsert; export const medCabinetItems = pgTable("med_cabinet_items", { id: serial("id").primaryKey(), familyId: integer("family_id").notNull().references(() => medFamilyGroups.id, { onDelete: "cascade" }), name: text("name").notNull(), form: text("form"), dosage: text("dosage"), quantity: real("quantity").default(0), unit: text("unit").default("шт"), expiryDate: date("expiry_date"), indication: text("indication"), location: text("location"), photoUrl: text("photo_url"), addedBy: integer("added_by").notNull().references(() => users.id, { onDelete: "cascade" }), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }, (table) => ({ familyIdx: index("med_cabinet_items_family_idx").on(table.familyId), expiryIdx: index("med_cabinet_items_expiry_idx").on(table.expiryDate), })); export type MedCabinetItem = typeof medCabinetItems.$inferSelect; export type InsertMedCabinetItem = typeof medCabinetItems.$inferInsert; export const medCabinetStock = pgTable("med_cabinet_stock", { id: serial("id").primaryKey(), cabinetItemId: integer("cabinet_item_id").notNull().references(() => medCabinetItems.id, { onDelete: "cascade" }), change: real("change").notNull(), reason: text("reason"), note: text("note"), changedBy: integer("changed_by").notNull().references(() => users.id, { onDelete: "cascade" }), createdAt: timestamp("created_at").defaultNow().notNull(), }, (table) => ({ itemIdx: index("med_cabinet_stock_item_idx").on(table.cabinetItemId), })); export type MedCabinetStock = typeof medCabinetStock.$inferSelect; export type InsertMedCabinetStock = typeof medCabinetStock.$inferInsert; export const medMedicalRecords = pgTable("med_medical_records", { id: serial("id").primaryKey(), familyId: integer("family_id").notNull().references(() => medFamilyGroups.id, { onDelete: "cascade" }), memberId: integer("member_id").notNull().references(() => medFamilyMembers.id, { onDelete: "cascade" }), title: text("title").notNull(), recordType: text("record_type").notNull(), recordDate: date("record_date"), doctorName: text("doctor_name"), clinicName: text("clinic_name"), rawText: text("raw_text"), aiSummary: text("ai_summary"), aiFindings: jsonb("ai_findings").$type>(), fileUrls: text("file_urls").array().default(sql`'{}'::text[]`), addedBy: integer("added_by").notNull().references(() => users.id, { onDelete: "cascade" }), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }, (table) => ({ familyIdx: index("med_medical_records_family_idx").on(table.familyId), memberIdx: index("med_medical_records_member_idx").on(table.memberId), dateIdx: index("med_medical_records_date_idx").on(table.recordDate), })); export type MedMedicalRecord = typeof medMedicalRecords.$inferSelect; export type InsertMedMedicalRecord = typeof medMedicalRecords.$inferInsert; export const medDailyCheckins = pgTable("med_daily_checkins", { id: serial("id").primaryKey(), familyId: integer("family_id").notNull().references(() => medFamilyGroups.id, { onDelete: "cascade" }), memberId: integer("member_id").references(() => medFamilyMembers.id, { onDelete: "cascade" }), checkInDate: date("check_in_date").notNull(), status: text("status").$type<"pending" | "answered" | "skipped">().default("pending").notNull(), question: text("question").notNull(), answerText: text("answer_text"), aiSummary: text("ai_summary"), aiAlert: text("ai_alert"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }, (table) => ({ familyIdx: index("med_daily_checkins_family_idx").on(table.familyId), memberIdx: index("med_daily_checkins_member_idx").on(table.memberId), dateIdx: index("med_daily_checkins_date_idx").on(table.checkInDate), })); export type MedDailyCheckin = typeof medDailyCheckins.$inferSelect; export type InsertMedDailyCheckin = typeof medDailyCheckins.$inferInsert; export const medBotBindings = pgTable("med_bot_bindings", { id: serial("id").primaryKey(), userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), telegramChatId: text("telegram_chat_id"), maxChatId: text("max_chat_id"), telegramUsername: text("telegram_username"), maxUsername: text("max_username"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }, (table) => ({ userOrgIdx: uniqueIndex("med_bot_bindings_user_org_idx").on(table.userId, table.organizationId), telegramIdx: uniqueIndex("med_bot_bindings_telegram_idx").on(table.organizationId, table.telegramChatId), maxIdx: uniqueIndex("med_bot_bindings_max_idx").on(table.organizationId, table.maxChatId), })); export type MedBotBinding = typeof medBotBindings.$inferSelect; export type InsertMedBotBinding = typeof medBotBindings.$inferInsert; // ===================== // GPS-мониторинг (объекты, треки, геозоны) // ===================== // Объекты мониторинга: транспорт ('vehicle') или люди ('person') export const gpsAssets = pgTable("gps_assets", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), name: text("name").notNull(), type: text("type").$type<"vehicle" | "person">().notNull().default("vehicle"), userId: integer("user_id").references(() => users.id), externalId: text("external_id"), // IMEI/идентификатор трекера (уникален в рамках организации) color: text("color").default("#3b82f6"), isActive: boolean("is_active").default(true), isOnline: boolean("is_online").default(false), lastLat: doublePrecision("last_lat"), lastLng: doublePrecision("last_lng"), lastSpeed: doublePrecision("last_speed"), lastCourse: doublePrecision("last_course"), lastSeenAt: timestamp("last_seen_at"), // Время последнего GPS-фикса по устройству (из stale re-send = замороженный fix, либо свежая принятая точка) lastGpsFixAt: timestamp("last_gps_fix_at"), // время ПРИЁМА последней точки сервером (онлайн-индикатор) lastRecordedAt: timestamp("last_recorded_at"), // время последней точки по устройству (fixTime) // Режим «потерян GPS»: трекер присылает stale re-send'ы, свежие точки считаются LBS gpsLost: boolean("gps_lost").notNull().default(false), // Последняя принятая точка — LBS (координаты сотовой вышки, не GPS) lastIsLbs: boolean("last_is_lbs").notNull().default(false), createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ orgIdx: index("gps_assets_org_idx").on(table.organizationId), orgExternalIdx: uniqueIndex("gps_assets_org_external_idx").on(table.organizationId, table.externalId), userIdx: index("gps_assets_user_idx").on(table.userId), })); export type GpsAsset = typeof gpsAssets.$inferSelect; export type InsertGpsAsset = typeof gpsAssets.$inferInsert; // История позиций (треки) export const gpsPositions = pgTable("gps_positions", { id: bigserial("id", { mode: "number" }).primaryKey(), assetId: integer("asset_id").notNull().references(() => gpsAssets.id, { onDelete: "cascade" }), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), lat: doublePrecision("lat").notNull(), lng: doublePrecision("lng").notNull(), speed: doublePrecision("speed"), course: doublePrecision("course"), accuracy: doublePrecision("accuracy"), // Точка — координаты сотовой вышки (LBS), а не GPS-фикс; сохраняется и // отображается (пунктир), но не участвует в логике геозон isLbs: boolean("is_lbs").notNull().default(false), recordedAt: timestamp("recorded_at").notNull(), createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ assetRecordedIdx: index("gps_positions_asset_recorded_idx").on(table.assetId, table.recordedAt), orgRecordedIdx: index("gps_positions_org_recorded_idx").on(table.organizationId, table.recordedAt), })); export type GpsPosition = typeof gpsPositions.$inferSelect; export type InsertGpsPosition = typeof gpsPositions.$inferInsert; // Геозоны (полигоны; polygon — массив пар [lat, lng]) export const gpsGeozones = pgTable("gps_geozones", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), name: text("name").notNull(), polygon: jsonb("polygon").$type>().notNull(), color: text("color").default("#f59e0b"), notifyEnter: boolean("notify_enter").default(true), notifyExit: boolean("notify_exit").default(true), isActive: boolean("is_active").default(true), createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ orgIdx: index("gps_geozones_org_idx").on(table.organizationId), })); export type GpsGeozone = typeof gpsGeozones.$inferSelect; export type InsertGpsGeozone = typeof gpsGeozones.$inferInsert; // События входа/выхода из геозон export const gpsGeozoneEvents = pgTable("gps_geozone_events", { id: bigserial("id", { mode: "number" }).primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), geozoneId: integer("geozone_id").notNull().references(() => gpsGeozones.id, { onDelete: "cascade" }), assetId: integer("asset_id").notNull().references(() => gpsAssets.id, { onDelete: "cascade" }), event: text("event").$type<"enter" | "exit">().notNull(), lat: doublePrecision("lat"), lng: doublePrecision("lng"), createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ assetIdx: index("gps_geozone_events_asset_idx").on(table.assetId, table.createdAt), orgIdx: index("gps_geozone_events_org_idx").on(table.organizationId, table.createdAt), })); export type GpsGeozoneEvent = typeof gpsGeozoneEvents.$inferSelect; export type InsertGpsGeozoneEvent = typeof gpsGeozoneEvents.$inferInsert; // Текущее состояние «объект внутри геозоны» (для детекта входа/выхода) export const gpsGeozoneStates = pgTable("gps_geozone_states", { assetId: integer("asset_id").notNull().references(() => gpsAssets.id, { onDelete: "cascade" }), geozoneId: integer("geozone_id").notNull().references(() => gpsGeozones.id, { onDelete: "cascade" }), inside: boolean("inside").notNull(), updatedAt: timestamp("updated_at").defaultNow(), }, (table) => ({ pk: uniqueIndex("gps_geozone_states_pk").on(table.assetId, table.geozoneId), })); export type GpsGeozoneState = typeof gpsGeozoneStates.$inferSelect; export type InsertGpsGeozoneState = typeof gpsGeozoneStates.$inferInsert; // Группы объектов (для фильтров на карте) export const gpsGroups = pgTable("gps_groups", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), name: text("name").notNull(), createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ orgIdx: index("gps_groups_org_idx").on(table.organizationId), })); export type GpsGroup = typeof gpsGroups.$inferSelect; export type InsertGpsGroup = typeof gpsGroups.$inferInsert; // Связь групп и объектов (многие-ко-многим) export const gpsGroupAssets = pgTable("gps_group_assets", { groupId: integer("group_id").notNull().references(() => gpsGroups.id, { onDelete: "cascade" }), assetId: integer("asset_id").notNull().references(() => gpsAssets.id, { onDelete: "cascade" }), }, (table) => ({ pk: uniqueIndex("gps_group_assets_pk").on(table.groupId, table.assetId), })); export type GpsGroupAsset = typeof gpsGroupAssets.$inferSelect; export type InsertGpsGroupAsset = typeof gpsGroupAssets.$inferInsert; // Подписчики на события объекта export const gpsAssetSubscribers = pgTable("gps_asset_subscribers", { id: serial("id").primaryKey(), assetId: integer("asset_id").notNull().references(() => gpsAssets.id, { onDelete: "cascade" }), userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), onEnter: boolean("on_enter").default(true), onExit: boolean("on_exit").default(true), onOffline: boolean("on_offline").default(false), }, (table) => ({ assetUserIdx: uniqueIndex("gps_asset_subscribers_asset_user_idx").on(table.assetId, table.userId), })); export type GpsAssetSubscriber = typeof gpsAssetSubscribers.$inferSelect; export type InsertGpsAssetSubscriber = typeof gpsAssetSubscribers.$inferInsert; // Настройки GPS-модуля организации (фильтр джиттера и т.п.) export const gpsSettings = pgTable("gps_settings", { organizationId: integer("organization_id").primaryKey().references(() => organizations.id, { onDelete: "cascade" }), jitterMeters: integer("jitter_meters").notNull().default(25), maxJumpSpeedKmh: integer("max_jump_speed_kmh").notNull().default(250), tripStopMinutes: integer("trip_stop_minutes").notNull().default(10), gpsLostHours: integer("gps_lost_hours").notNull().default(5), // Порог «далеко от дороги» (м) для фильтра LBS-точек в режиме потери GPS; 0 = фильтр выключен offRoadMeters: integer("off_road_meters").notNull().default(200), defaultLat: doublePrecision("default_lat"), defaultLng: doublePrecision("default_lng"), defaultZoom: integer("default_zoom"), allowedUserIds: jsonb("allowed_user_ids").$type(), allowedRoleIds: jsonb("allowed_role_ids").$type(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }); export type GpsSettings = typeof gpsSettings.$inferSelect; export type InsertGpsSettings = typeof gpsSettings.$inferInsert;