Files
iistwin/shared/schema.ts

4137 lines
186 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { sql, relations } 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),
});
// 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;
// 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 }),
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),
}));
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" }),
refreshToken: varchar("refresh_token", { length: 500 }).notNull().unique(),
familyId: uuid("family_id").notNull(),
familyCreatedAt: timestamp("family_created_at").notNull(),
parentRefreshToken: varchar("parent_refresh_token", { length: 500 }),
isReplaced: boolean("is_replaced").notNull().default(false),
replacedByToken: varchar("replaced_by_token", { length: 500 }),
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),
}));
// 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 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 — роль поля в Гантте
createdAt: timestamp("created_at").defaultNow(),
}, (table) => ({
// Уникальность кода поля внутри формы
uniqueFieldCode: unique().on(table.formId, table.code),
// Композитный уникальный ключ для 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<typeof insertTaskRelationSchema>;
// 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'
mentionedUserIds: jsonb("mentioned_user_ids"), // массив ID упомянутых пользователей
botButtons: jsonb("bot_buttons"), // кнопки для бот-сообщений [{text, callbackData, style, row}]
attachments: jsonb("attachments").$type<Array<{ url: string; name: string; size: number; mimeType?: string }>>(),
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 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(),
});
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 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<typeof insertOrganizationSchema>;
export type InsertUser = z.infer<typeof insertUserSchema>;
export type InsertUserSession = z.infer<typeof insertUserSessionSchema>;
export type InsertInvitation = z.infer<typeof insertInvitationSchema>;
export type CreateInvitation = z.infer<typeof createInvitationSchema>;
export type RegisterByInvitation = z.infer<typeof registerByInvitationSchema>;
export type UserCustomField = typeof userCustomFields.$inferSelect;
export type UserCustomValue = typeof userCustomValues.$inferSelect;
export type CreateUserCustomField = z.infer<typeof createUserCustomFieldSchema>;
export type UpdateUserCustomField = z.infer<typeof updateUserCustomFieldSchema>;
export type SetUserCustomValue = z.infer<typeof setUserCustomValueSchema>;
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 InsertUserProfileTab = z.infer<typeof insertUserProfileTabSchema>;
export type InsertUserProfileField = z.infer<typeof insertUserProfileFieldSchema>;
export type InsertUserProfileFieldValue = z.infer<typeof insertUserProfileFieldValueSchema>;
export type InsertUserProfileAuditLog = z.infer<typeof insertUserProfileAuditLogSchema>;
export type InsertForm = z.infer<typeof insertFormSchema>;
export type InsertFormTab = z.infer<typeof insertFormTabSchema>;
export type InsertFormField = z.infer<typeof insertFormFieldSchema>;
export type InsertFormStatus = z.infer<typeof insertFormStatusSchema>;
export type InsertStatusTransition = z.infer<typeof insertStatusTransitionSchema>;
export type InsertTask = z.infer<typeof insertTaskSchema>;
export type InsertTaskFieldValue = z.infer<typeof insertTaskFieldValueSchema>;
export type InsertFieldHistory = z.infer<typeof insertFieldHistorySchema>;
export type InsertTaskMessage = z.infer<typeof insertTaskMessageSchema>;
export type InsertUserNotification = z.infer<typeof insertUserNotificationSchema>;
export type InsertMessageRead = z.infer<typeof insertMessageReadSchema>;
export type RegisterOrganization = z.infer<typeof registerOrganizationSchema>;
export type LoginRequest = z.infer<typeof loginSchema>;
export type CreateFormRequest = z.infer<typeof createFormSchema>;
export type CreateTaskRequest = z.infer<typeof createTaskSchema>;
export type UpdateTaskRequest = z.infer<typeof updateTaskSchema>;
export type TransitionTaskRequest = z.infer<typeof transitionTaskSchema>;
export type CreateTaskMessageRequest = z.infer<typeof createTaskMessageSchema>;
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<typeof insertBookmarkFolderSchema>;
export type InsertBookmark = z.infer<typeof insertBookmarkSchema>;
export type CreateBookmarkFolder = z.infer<typeof createBookmarkFolderSchema>;
export type UpdateBookmarkFolder = z.infer<typeof updateBookmarkFolderSchema>;
export type CreateBookmark = z.infer<typeof createBookmarkSchema>;
export type UpdateBookmark = z.infer<typeof updateBookmarkSchema>;
// =====================================================
// 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<Array<{ url: string; apiKey?: string; name?: string }>>(),
// 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<typeof insertBotSchema>;
export type InsertBotSubscription = z.infer<typeof insertBotSubscriptionSchema>;
export type InsertBotSession = z.infer<typeof insertBotSessionSchema>;
export type CreateBot = z.infer<typeof createBotSchema>;
export type UpdateBot = z.infer<typeof updateBotSchema>;
export type CreateBotSubscription = z.infer<typeof createBotSubscriptionSchema>;
export type BotLogin = z.infer<typeof botLoginSchema>;
export type BotMessage = z.infer<typeof botMessageSchema>;
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<Array<{
name: string;
type: string;
options?: string[];
isRequired?: boolean;
}>>(),
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<number[] | null>().default(null),
treeDisplayColumns: jsonb("tree_display_columns").$type<number[] | null>().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<number[]>().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<string[]>(),
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<typeof insertDataTableSchema>;
export type InsertDataTableRow = z.infer<typeof insertDataTableRowSchema>;
export type InsertDataTablePermission = z.infer<typeof insertDataTablePermissionSchema>;
export type InsertDataTableAccessRule = typeof dataTableAccessRules.$inferInsert;
export type CreateDataTable = z.infer<typeof createDataTableSchema>;
export type UpdateDataTable = z.infer<typeof updateDataTableSchema>;
export type CreateDataTableRow = z.infer<typeof createDataTableRowSchema>;
export type SetTablePermission = z.infer<typeof setTablePermissionSchema>;
export type SyncDataTable = z.infer<typeof syncDataTableSchema>;
export type DiffDataTable = z.infer<typeof diffDataTableSchema>;
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<typeof insertExternalServiceSchema>;
// ========================================
// 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<typeof insertNotificationEventTypeSchema>;
export type InsertNotificationSubscription = z.infer<typeof insertNotificationSubscriptionSchema>;
export type CreateSubscription = z.infer<typeof createSubscriptionSchema>;
export type UpdateSubscription = z.infer<typeof updateSubscriptionSchema>;
// 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<string, any>;
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<typeof insertRegularTableRowSchema>;
// 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<typeof insertFieldTemplateSchema>;
// =====================
// 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<typeof insertCustomTabModuleSchema>;
// =====================
// 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<typeof insertTaskTabValueSchema>;
// =====================
// 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<typeof insertDeviceTokenSchema>;
// =====================
// 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<typeof insertUserPresenceSchema>;
// =====================
// 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<ApiKeyScopes>(),
// Привязка ключа к боту (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),
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<typeof insertCustomPageSchema>;
// ── 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<Record<string, unknown>>(),
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<typeof insertAutomationSchema>;
// 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<Array<{ type: 'user'; userId: number } | { type: 'role'; role: string } | { type: 'orgRole'; roleId: number }>>().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<typeof insertTaskReminderSchema>;
export type ReminderRecipient = { type: 'user'; userId: number } | { type: 'role'; role: string } | { type: 'orgRole'; roleId: number };
// 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<number[]>(),
attachments: jsonb("attachments").$type<Array<{ url: string; name: string; size: number; mimeType?: string }>>(),
botButtons: jsonb("bot_buttons").$type<Array<{ text: string; callbackData: string; style?: 'primary' | 'secondary' | 'destructive' | 'outline'; row?: number }>>(),
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<string[]>().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<string[]>().default([]), // список разрешённых моделей
customHeaders: jsonb("custom_headers").$type<Record<string, string>>().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, any> } | 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<Array<{
assetId: number;
position: 'header' | 'footer' | 'top-right' | 'bottom-right' | 'bottom-left' | 'custom';
page?: 'first' | 'last' | 'all';
customX?: number;
customY?: number;
width?: number;
height?: number;
opacity?: number;
}>>().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, any> } | 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<Record<string, string>>().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<T> {
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<string, string>;
}
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<Array<{ finding: string; value: string; norm?: string }>>(),
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)
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"),
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<Array<[number, number]>>().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),
defaultLat: doublePrecision("default_lat"),
defaultLng: doublePrecision("default_lng"),
defaultZoom: integer("default_zoom"),
allowedUserIds: jsonb("allowed_user_ids").$type<number[]>(),
allowedRoleIds: jsonb("allowed_role_ids").$type<number[]>(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export type GpsSettings = typeof gpsSettings.$inferSelect;
export type InsertGpsSettings = typeof gpsSettings.$inferInsert;