4245 lines
194 KiB
TypeScript
4245 lines
194 KiB
TypeScript
import { sql, relations, getTableColumns } from "drizzle-orm";
|
||
import { pgTable, text, varchar, integer, boolean, timestamp, jsonb, serial, bigserial, doublePrecision, unique, foreignKey, uniqueIndex, index, numeric, real, date, uuid, type AnyPgColumn, customType } from "drizzle-orm/pg-core";
|
||
import { createInsertSchema } from "drizzle-zod";
|
||
import { z } from "zod";
|
||
|
||
// pgvector custom type (Drizzle не поддерживает vector нативно)
|
||
const vector = (dimensions: number) => customType<{ data: number[]; driverData: string }>({
|
||
dataType() { return `vector(${dimensions})`; },
|
||
toDriver(value: number[]): string { return `[${value.join(",")}]`; },
|
||
fromDriver(value: string): number[] { return value.slice(1, -1).split(",").map(Number); },
|
||
});
|
||
|
||
// Super Admins table (system-level, not tied to any organization)
|
||
export const superAdmins = pgTable("super_admins", {
|
||
id: serial("id").primaryKey(),
|
||
email: varchar("email", { length: 255 }).notNull().unique("super_admins_email_key"),
|
||
passwordHash: varchar("password_hash", { length: 255 }).notNull(),
|
||
name: varchar("name", { length: 200 }).notNull(),
|
||
createdAt: timestamp("created_at").defaultNow(),
|
||
lastLogin: timestamp("last_login"),
|
||
});
|
||
|
||
export type SuperAdmin = typeof superAdmins.$inferSelect;
|
||
export type InsertSuperAdmin = typeof superAdmins.$inferInsert;
|
||
export const insertSuperAdminSchema = createInsertSchema(superAdmins).omit({ id: true, createdAt: true, lastLogin: true });
|
||
|
||
// Organizations table
|
||
export const organizations = pgTable("organizations", {
|
||
id: serial("id").primaryKey(),
|
||
name: varchar("name", { length: 100 }).notNull().unique(),
|
||
slug: varchar("slug", { length: 50 }).notNull().unique(),
|
||
displayName: varchar("display_name", { length: 200 }).notNull(),
|
||
logoUrl: varchar("logo_url", { length: 500 }),
|
||
themeColor: varchar("theme_color", { length: 7 }).default("#1976d2"),
|
||
activeSkinId: varchar("active_skin_id", { length: 50 }).default("iistwin"),
|
||
createdAt: timestamp("created_at").defaultNow(),
|
||
updatedAt: timestamp("updated_at").defaultNow(),
|
||
isActive: boolean("is_active").default(true),
|
||
billingBlocked: boolean("billing_blocked").default(false),
|
||
medscheduleEnabled: boolean("medschedule_enabled").notNull().default(false),
|
||
});
|
||
|
||
// Organization skins
|
||
export const organizationSkins = pgTable("organization_skins", {
|
||
id: serial("id").primaryKey(),
|
||
organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
|
||
skinId: varchar("skin_id", { length: 50 }).notNull(),
|
||
name: varchar("name", { length: 100 }).notNull(),
|
||
description: text("description"),
|
||
tokens: jsonb("tokens").notNull(),
|
||
isEditable: boolean("is_editable").default(true),
|
||
isActive: boolean("is_active").default(false),
|
||
createdBy: integer("created_by").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||
createdAt: timestamp("created_at").defaultNow(),
|
||
updatedAt: timestamp("updated_at").defaultNow(),
|
||
}, (table) => ({
|
||
uniqueOrgSkin: unique().on(table.organizationId, table.skinId),
|
||
}));
|
||
|
||
export type OrganizationSkin = typeof organizationSkins.$inferSelect;
|
||
export type InsertOrganizationSkin = typeof organizationSkins.$inferInsert;
|
||
|
||
// Billing settings per organization (1:1)
|
||
export const organizationBilling = pgTable("organization_billing", {
|
||
organizationId: integer("organization_id").primaryKey().references(() => organizations.id, { onDelete: "cascade" }),
|
||
pricePerUser: numeric("price_per_user", { precision: 10, scale: 2 }).notNull().default("0"),
|
||
balance: numeric("balance", { precision: 10, scale: 2 }).notNull().default("0"),
|
||
currency: varchar("currency", { length: 10 }).notNull().default("RUB"),
|
||
nextBillingDate: date("next_billing_date"),
|
||
blockedAt: timestamp("blocked_at"),
|
||
updatedAt: timestamp("updated_at").defaultNow(),
|
||
});
|
||
|
||
export type OrganizationBilling = typeof organizationBilling.$inferSelect;
|
||
export type InsertOrganizationBilling = typeof organizationBilling.$inferInsert;
|
||
|
||
// Billing transactions (credits and debits)
|
||
export const billingTransactions = pgTable("billing_transactions", {
|
||
id: serial("id").primaryKey(),
|
||
organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
|
||
amount: numeric("amount", { precision: 10, scale: 2 }).notNull(),
|
||
type: varchar("type", { length: 10 }).notNull(), // 'credit' | 'debit'
|
||
description: text("description"),
|
||
createdAt: timestamp("created_at").defaultNow(),
|
||
createdBy: integer("created_by"), // nullable super admin ID
|
||
});
|
||
|
||
export type BillingTransaction = typeof billingTransactions.$inferSelect;
|
||
export type InsertBillingTransaction = typeof billingTransactions.$inferInsert;
|
||
|
||
// Billing payments (self-service top-ups via payment provider)
|
||
export const billingPayments = pgTable("billing_payments", {
|
||
id: serial("id").primaryKey(),
|
||
organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
|
||
provider: varchar("provider", { length: 30 }).notNull(),
|
||
externalId: varchar("external_id", { length: 255 }).notNull().unique("billing_payments_external_id_key"),
|
||
amount: numeric("amount", { precision: 10, scale: 2 }).notNull(),
|
||
currency: varchar("currency", { length: 10 }).notNull().default("RUB"),
|
||
status: varchar("status", { length: 20 }).notNull().default("pending"), // 'pending' | 'succeeded' | 'canceled'
|
||
idempotencyKey: varchar("idempotency_key", { length: 255 }).notNull().unique("billing_payments_idempotency_key_key"),
|
||
metadata: jsonb("metadata"),
|
||
createdAt: timestamp("created_at").defaultNow(),
|
||
updatedAt: timestamp("updated_at").defaultNow(),
|
||
}, (table) => ({
|
||
orgStatusIdx: index("billing_payments_org_status_idx").on(table.organizationId, table.status),
|
||
}));
|
||
|
||
export type BillingPayment = typeof billingPayments.$inferSelect;
|
||
export type InsertBillingPayment = typeof billingPayments.$inferInsert;
|
||
|
||
// Users table
|
||
export const users = pgTable("users", {
|
||
id: serial("id").primaryKey(),
|
||
organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
|
||
email: varchar("email", { length: 255 }).notNull(),
|
||
passwordHash: varchar("password_hash", { length: 255 }).notNull(),
|
||
firstName: varchar("first_name", { length: 100 }).notNull(),
|
||
lastName: varchar("last_name", { length: 100 }).notNull(),
|
||
middleName: varchar("middle_name", { length: 100 }),
|
||
position: varchar("position", { length: 200 }),
|
||
phone: varchar("phone", { length: 50 }),
|
||
additionalPhones: jsonb("additional_phones").default(sql`'[]'`),
|
||
additionalEmails: jsonb("additional_emails").default(sql`'[]'`),
|
||
appRole: varchar("app_role", { length: 100 }).notNull().default("user"), // 'admin', 'user', 'accountant'
|
||
// Флаги доступа к модулям (матрица на /users): GPS и Финансы
|
||
gpsAccess: boolean("gps_access").notNull().default(true),
|
||
financeAccess: boolean("finance_access").notNull().default(true),
|
||
isActive: boolean("is_active").default(true),
|
||
emailVerified: boolean("email_verified").default(false),
|
||
verificationToken: varchar("verification_token", { length: 255 }),
|
||
resetPasswordToken: varchar("reset_password_token", { length: 255 }),
|
||
resetPasswordTokenHash: varchar("reset_password_token_hash", { length: 64 }),
|
||
resetPasswordExpires: timestamp("reset_password_expires"),
|
||
lastLogin: timestamp("last_login"),
|
||
lastActivityAt: timestamp("last_activity_at"),
|
||
statusId: integer("status_id"),
|
||
createdAt: timestamp("created_at").defaultNow(),
|
||
updatedAt: timestamp("updated_at").defaultNow(),
|
||
}, (table) => ({
|
||
// Быстрый поиск пользователей организации по роли
|
||
orgRoleIdx: index("users_org_role_idx").on(table.organizationId, table.appRole),
|
||
}));
|
||
|
||
// Колонки users без чувствительных данных (хэш пароля, токены подтверждения и сброса).
|
||
// Использовать во всех массовых/клиентских выборках пользователей.
|
||
const { passwordHash: _ph, verificationToken: _vt, resetPasswordToken: _rpt, resetPasswordTokenHash: _rpth, resetPasswordExpires: _rpe, ...restSafeUserColumns } = getTableColumns(users);
|
||
export const safeUserColumns = restSafeUserColumns;
|
||
|
||
export const userStatuses = pgTable("user_statuses", {
|
||
id: serial("id").primaryKey(),
|
||
organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
|
||
name: varchar("name", { length: 100 }).notNull(),
|
||
color: varchar("color", { length: 7 }).default("#e5e7eb"),
|
||
isDefault: boolean("is_default").default(false),
|
||
position: integer("position").default(0),
|
||
createdAt: timestamp("created_at").defaultNow(),
|
||
}, (table) => ({
|
||
orgIdx: index("user_statuses_org_idx").on(table.organizationId),
|
||
}));
|
||
|
||
export type UserStatus = typeof userStatuses.$inferSelect;
|
||
export type InsertUserStatus = typeof userStatuses.$inferInsert;
|
||
|
||
// Замещение пользователей (delegation): кто кого замещает на период.
|
||
// formId IS NULL — заместитель по умолчанию для всех форм организации.
|
||
export const userDelegations = pgTable("user_delegations", {
|
||
id: serial("id").primaryKey(),
|
||
organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
|
||
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), // кто отсутствует
|
||
delegateUserId: integer("delegate_user_id").notNull().references(() => users.id, { onDelete: "cascade" }), // заместитель
|
||
formId: integer("form_id").references(() => forms.id, { onDelete: "cascade" }), // NULL = по умолчанию
|
||
dateFrom: date("date_from", { mode: "string" }).notNull(),
|
||
dateTo: date("date_to", { mode: "string" }).notNull(),
|
||
isActive: boolean("is_active").notNull().default(true),
|
||
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
|
||
createdAt: timestamp("created_at").defaultNow(),
|
||
}, (table) => ({
|
||
userActiveIdx: index("user_delegations_user_active_idx").on(table.userId, table.isActive),
|
||
orgIdx: index("user_delegations_org_idx").on(table.organizationId),
|
||
}));
|
||
|
||
export type UserDelegation = typeof userDelegations.$inferSelect;
|
||
export type InsertUserDelegation = typeof userDelegations.$inferInsert;
|
||
|
||
// User sessions table
|
||
export const userSessions = pgTable("user_sessions", {
|
||
id: serial("id").primaryKey(),
|
||
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||
// Шаг 0.6: plain refresh-токены в БД не хранятся — только sha256-хэши.
|
||
// refresh_token/parent_refresh_token/replaced_by_token — legacy-колонки (не пишутся, удаление отложено).
|
||
refreshToken: varchar("refresh_token", { length: 500 }).unique(),
|
||
refreshTokenHash: varchar("refresh_token_hash", { length: 64 }).notNull().unique("user_sessions_refresh_token_hash_key"),
|
||
familyId: uuid("family_id").notNull(),
|
||
familyCreatedAt: timestamp("family_created_at").notNull(),
|
||
parentRefreshToken: varchar("parent_refresh_token", { length: 500 }),
|
||
parentRefreshTokenHash: varchar("parent_refresh_token_hash", { length: 64 }),
|
||
isReplaced: boolean("is_replaced").notNull().default(false),
|
||
replacedByToken: varchar("replaced_by_token", { length: 500 }),
|
||
replacedByTokenHash: varchar("replaced_by_token_hash", { length: 64 }),
|
||
isRevoked: boolean("is_revoked").notNull().default(false),
|
||
expiresAt: timestamp("expires_at").notNull(),
|
||
remember: boolean("remember").notNull().default(false),
|
||
createdAt: timestamp("created_at").defaultNow(),
|
||
ipAddress: varchar("ip_address", { length: 45 }),
|
||
userAgent: text("user_agent"),
|
||
}, (table) => ({
|
||
userIdIdx: index("user_sessions_user_id_idx").on(table.userId),
|
||
familyIdIdx: index("user_sessions_family_id_idx").on(table.familyId),
|
||
parentRefreshTokenIdx: index("user_sessions_parent_refresh_token_idx").on(table.parentRefreshToken),
|
||
parentRefreshTokenHashIdx: index("user_sessions_parent_refresh_token_hash_idx").on(table.parentRefreshTokenHash),
|
||
}));
|
||
|
||
// Invitations table - for inviting users to organization
|
||
export const invitations = pgTable("invitations", {
|
||
id: serial("id").primaryKey(),
|
||
organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
|
||
email: varchar("email", { length: 255 }).notNull(),
|
||
token: varchar("token", { length: 64 }).notNull().unique("invitations_token_key"),
|
||
createdBy: integer("created_by").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||
createdAt: timestamp("created_at").defaultNow(),
|
||
expiresAt: timestamp("expires_at").notNull(),
|
||
usedAt: timestamp("used_at"),
|
||
});
|
||
|
||
export const invitationsRelations = relations(invitations, ({ one }) => ({
|
||
organization: one(organizations, {
|
||
fields: [invitations.organizationId],
|
||
references: [organizations.id],
|
||
}),
|
||
createdByUser: one(users, {
|
||
fields: [invitations.createdBy],
|
||
references: [users.id],
|
||
}),
|
||
}));
|
||
|
||
// User custom fields definitions
|
||
export const userCustomFields = pgTable("user_custom_fields", {
|
||
id: serial("id").primaryKey(),
|
||
organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
|
||
name: varchar("name", { length: 200 }).notNull(),
|
||
code: varchar("code", { length: 100 }).notNull(),
|
||
type: varchar("type", { length: 50 }).notNull(), // text, number, date, select, user, checkbox
|
||
options: jsonb("options"), // For select type: array of options
|
||
isRequired: boolean("is_required").default(false),
|
||
position: integer("position").default(0),
|
||
createdAt: timestamp("created_at").defaultNow(),
|
||
updatedAt: timestamp("updated_at").defaultNow(),
|
||
});
|
||
|
||
// User custom field values
|
||
export const userCustomValues = pgTable("user_custom_values", {
|
||
id: serial("id").primaryKey(),
|
||
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||
fieldId: integer("field_id").notNull().references(() => userCustomFields.id, { onDelete: "cascade" }),
|
||
value: text("value"),
|
||
createdAt: timestamp("created_at").defaultNow(),
|
||
updatedAt: timestamp("updated_at").defaultNow(),
|
||
});
|
||
|
||
export const userCustomFieldsRelations = relations(userCustomFields, ({ one, many }) => ({
|
||
organization: one(organizations, {
|
||
fields: [userCustomFields.organizationId],
|
||
references: [organizations.id],
|
||
}),
|
||
values: many(userCustomValues),
|
||
}));
|
||
|
||
export const userCustomValuesRelations = relations(userCustomValues, ({ one }) => ({
|
||
user: one(users, {
|
||
fields: [userCustomValues.userId],
|
||
references: [users.id],
|
||
}),
|
||
field: one(userCustomFields, {
|
||
fields: [userCustomValues.fieldId],
|
||
references: [userCustomFields.id],
|
||
}),
|
||
}));
|
||
|
||
// User profile tabs table (analogous to formTabs for users)
|
||
export const userProfileTabs = pgTable("user_profile_tabs", {
|
||
id: serial("id").primaryKey(),
|
||
organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
|
||
name: varchar("name", { length: 200 }).notNull(),
|
||
code: varchar("code", { length: 100 }).notNull(),
|
||
position: integer("position").notNull().default(0),
|
||
displayOrder: integer("display_order").default(0),
|
||
createdAt: timestamp("created_at").defaultNow(),
|
||
}, (table) => ({
|
||
uniqueTabCode: unique().on(table.organizationId, table.code),
|
||
}));
|
||
|
||
// User profile fields table (analogous to formFields for users)
|
||
export const userProfileFields = pgTable("user_profile_fields", {
|
||
id: serial("id").primaryKey(),
|
||
organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
|
||
tabId: integer("tab_id").references(() => userProfileTabs.id, { onDelete: "set null" }),
|
||
name: varchar("name", { length: 200 }).notNull(),
|
||
code: varchar("code", { length: 100 }).notNull(),
|
||
type: varchar("type", { length: 50 }).notNull(),
|
||
isRequired: boolean("is_required").default(false),
|
||
placeholder: text("placeholder"),
|
||
defaultValue: text("default_value"),
|
||
validationRules: jsonb("validation_rules"),
|
||
options: jsonb("options"),
|
||
position: integer("position").notNull().default(0),
|
||
displayOrder: integer("display_order").default(0),
|
||
linkedFormId: integer("linked_form_id").references(() => forms.id, { onDelete: "set null" }),
|
||
companyAutofill: jsonb("company_autofill"),
|
||
maxFileCount: integer("max_file_count"),
|
||
maxFileSizeMB: integer("max_file_size_mb"),
|
||
autoProlongation: boolean("auto_prolongation").default(false),
|
||
autoProlongationLabel: text("auto_prolongation_label"),
|
||
createdAt: timestamp("created_at").defaultNow(),
|
||
}, (table) => ({
|
||
uniqueFieldCode: unique().on(table.organizationId, table.code),
|
||
}));
|
||
|
||
// User profile field values table (analogous to taskFieldValues for users)
|
||
export const userProfileFieldValues = pgTable("user_profile_field_values", {
|
||
id: serial("id").primaryKey(),
|
||
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||
fieldId: integer("field_id").notNull().references(() => userProfileFields.id),
|
||
value: jsonb("value"),
|
||
createdAt: timestamp("created_at").defaultNow(),
|
||
updatedAt: timestamp("updated_at").defaultNow(),
|
||
}, (table) => ({
|
||
uniqueUserField: unique().on(table.userId, table.fieldId),
|
||
userFieldIdx: index("user_profile_field_values_user_field_idx").on(table.userId, table.fieldId),
|
||
}));
|
||
|
||
export const userProfileTabsRelations = relations(userProfileTabs, ({ one, many }) => ({
|
||
organization: one(organizations, {
|
||
fields: [userProfileTabs.organizationId],
|
||
references: [organizations.id],
|
||
}),
|
||
fields: many(userProfileFields),
|
||
}));
|
||
|
||
export const userProfileFieldsRelations = relations(userProfileFields, ({ one, many }) => ({
|
||
organization: one(organizations, {
|
||
fields: [userProfileFields.organizationId],
|
||
references: [organizations.id],
|
||
}),
|
||
tab: one(userProfileTabs, {
|
||
fields: [userProfileFields.tabId],
|
||
references: [userProfileTabs.id],
|
||
}),
|
||
values: many(userProfileFieldValues),
|
||
}));
|
||
|
||
export const userProfileFieldValuesRelations = relations(userProfileFieldValues, ({ one }) => ({
|
||
user: one(users, {
|
||
fields: [userProfileFieldValues.userId],
|
||
references: [users.id],
|
||
}),
|
||
field: one(userProfileFields, {
|
||
fields: [userProfileFieldValues.fieldId],
|
||
references: [userProfileFields.id],
|
||
}),
|
||
}));
|
||
|
||
// User field history table (numeric history of user profile fields with type history-number)
|
||
export const userFieldHistory = pgTable("user_field_history", {
|
||
id: serial("id").primaryKey(),
|
||
organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
|
||
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||
fieldId: integer("field_id").notNull().references(() => userProfileFields.id, { onDelete: "cascade" }),
|
||
value: numeric("value").notNull(),
|
||
taskId: integer("task_id").references(() => tasks.id, { onDelete: "set null" }), // задача, из которой выставлена оценка (может быть null)
|
||
createdAt: timestamp("created_at").defaultNow(),
|
||
}, (table) => ({
|
||
userFieldCreatedIdx: index("user_field_history_user_field_created_idx").on(table.userId, table.fieldId, table.createdAt),
|
||
orgIdx: index("user_field_history_org_idx").on(table.organizationId),
|
||
}));
|
||
|
||
export const userFieldHistoryRelations = relations(userFieldHistory, ({ one }) => ({
|
||
organization: one(organizations, {
|
||
fields: [userFieldHistory.organizationId],
|
||
references: [organizations.id],
|
||
}),
|
||
user: one(users, {
|
||
fields: [userFieldHistory.userId],
|
||
references: [users.id],
|
||
}),
|
||
field: one(userProfileFields, {
|
||
fields: [userFieldHistory.fieldId],
|
||
references: [userProfileFields.id],
|
||
}),
|
||
task: one(tasks, {
|
||
fields: [userFieldHistory.taskId],
|
||
references: [tasks.id],
|
||
}),
|
||
}));
|
||
|
||
// User profile audit log table (history of user profile changes)
|
||
export const userProfileAuditLog = pgTable("user_profile_audit_log", {
|
||
id: serial("id").primaryKey(),
|
||
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||
organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
|
||
action: varchar("action", { length: 50 }).notNull(), // 'user.created', 'user.updated', 'user.activated', 'user.deactivated', 'user.deleted', 'password.changed', 'invitation.created', 'user.role.assigned', 'user.role.removed', 'field.changed'
|
||
fieldId: integer("field_id").references(() => userProfileFields.id),
|
||
fieldName: text("field_name"),
|
||
oldValue: jsonb("old_value"),
|
||
newValue: jsonb("new_value"),
|
||
changedBy: integer("changed_by").references(() => users.id),
|
||
changedByName: text("changed_by_name"),
|
||
metadata: jsonb("metadata"),
|
||
createdAt: timestamp("created_at").defaultNow(),
|
||
}, (table) => ({
|
||
userCreatedIdx: index("user_profile_audit_log_user_created_idx").on(table.userId, table.createdAt.desc()),
|
||
}));
|
||
|
||
export const userProfileAuditLogRelations = relations(userProfileAuditLog, ({ one }) => ({
|
||
user: one(users, {
|
||
fields: [userProfileAuditLog.userId],
|
||
references: [users.id],
|
||
}),
|
||
field: one(userProfileFields, {
|
||
fields: [userProfileAuditLog.fieldId],
|
||
references: [userProfileFields.id],
|
||
}),
|
||
changedByUser: one(users, {
|
||
fields: [userProfileAuditLog.changedBy],
|
||
references: [users.id],
|
||
}),
|
||
}));
|
||
|
||
// Forms table
|
||
export const forms = pgTable("forms", {
|
||
id: serial("id").primaryKey(),
|
||
organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
|
||
name: varchar("name", { length: 200 }).notNull(),
|
||
description: text("description"),
|
||
createdBy: integer("created_by").notNull().references(() => users.id),
|
||
createdAt: timestamp("created_at").defaultNow(),
|
||
updatedAt: timestamp("updated_at").defaultNow(),
|
||
isActive: boolean("is_active").default(true),
|
||
chatLayout: varchar("chat_layout", { length: 20 }).default("default"), // 'default', 'left', 'tab'
|
||
chatEnabled: boolean("chat_enabled").default(true), // включить/отключить чат для формы
|
||
qrGenerationEnabled: boolean("qr_generation_enabled").default(false), // показывать кнопку генерации QR в реестре форм
|
||
systemFieldsTabId: integer("system_fields_tab_id"), // ID вкладки для системных полей
|
||
defaultTabId: integer("default_tab_id"), // ID вкладки по умолчанию
|
||
detailLayoutCode: text("detail_layout_code"), // JS-код кастомного оформления страницы задачи
|
||
titleTemplate: text("title_template"), // шаблон заголовка задачи: "{{field_code}} - {{other_field}}"
|
||
hiddenSystemFields: text("hidden_system_fields").array().default(sql`'{}'::text[]`), // скрытые системные поля: ['createdAt','createdBy']
|
||
aiSummary: text("ai_summary"), // авто-сгенерированное GPT-резюме формы для RAG-памяти
|
||
aiSummaryIsAuto: boolean("ai_summary_is_auto").default(true), // true = сгенерировано автоматически, false = отредактировано вручную
|
||
systemTabOrder: text("system_tab_order").array(), // порядок системных вкладок: ['subtasks', 'related', 'chat', 'history', 'access']
|
||
visibility: varchar("visibility", { length: 20 }).default("restricted"), // 'organization' = все видят, 'restricted' = только из таблицы правил
|
||
offlineCache: jsonb("offline_cache").$type<{
|
||
enabled: boolean;
|
||
maxTasks?: number;
|
||
strategy?: 'recent' | 'assigned' | 'all';
|
||
includeFields?: string[];
|
||
}>(), // настройки офлайн-кэша формы (null = не кэшировать)
|
||
folderId: integer("folder_id"),
|
||
}, (table) => ({
|
||
aiSummaryIdx: index("forms_ai_summary_idx").on(table.aiSummary),
|
||
folderIdx: index("forms_folder_idx").on(table.folderId),
|
||
}));
|
||
|
||
// Form Access Rules (права доступа к форме и её задачам)
|
||
export const formAccessRules = pgTable("form_access_rules", {
|
||
id: serial("id").primaryKey(),
|
||
formId: integer("form_id").notNull().references(() => forms.id, { onDelete: "cascade" }),
|
||
organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
|
||
targetType: varchar("target_type", { length: 10 }).notNull(), // 'user' | 'role'
|
||
targetId: integer("target_id").notNull(), // user_id или role_id
|
||
accessLevel: varchar("access_level", { length: 20 }).notNull().default("participate"), // 'participate' | 'view_all' | 'admin'
|
||
createdAt: timestamp("created_at").defaultNow(),
|
||
}, (table) => ({
|
||
uniqueFormTarget: unique("form_access_rules_unique").on(table.formId, table.targetType, table.targetId),
|
||
formIdx: index("form_access_rules_form_idx").on(table.formId),
|
||
orgIdx: index("form_access_rules_org_idx").on(table.organizationId),
|
||
}));
|
||
|
||
export type FormAccessRule = typeof formAccessRules.$inferSelect;
|
||
export type InsertFormAccessRule = typeof formAccessRules.$inferInsert;
|
||
|
||
// User offline subscriptions (какие формы пользователь хочет иметь доступными офлайн)
|
||
export const userOfflineSubscriptions = pgTable("user_offline_subscriptions", {
|
||
id: serial("id").primaryKey(),
|
||
organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
|
||
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||
formId: integer("form_id").notNull().references(() => forms.id, { onDelete: "cascade" }),
|
||
isActive: boolean("is_active").default(true),
|
||
includeRelatedTasks: boolean("include_related_tasks").default(true), // кэшировать связанные задачи
|
||
includeFiles: boolean("include_files").default(false), // кэшировать файлы задач
|
||
createdAt: timestamp("created_at").defaultNow(),
|
||
updatedAt: timestamp("updated_at").defaultNow(),
|
||
}, (table) => ({
|
||
uniqueUserForm: unique("user_offline_subscriptions_unique").on(table.userId, table.formId),
|
||
userIdx: index("user_offline_subscriptions_user_idx").on(table.userId),
|
||
formIdx: index("user_offline_subscriptions_form_idx").on(table.formId),
|
||
orgIdx: index("user_offline_subscriptions_org_idx").on(table.organizationId),
|
||
}));
|
||
|
||
export type UserOfflineSubscription = typeof userOfflineSubscriptions.$inferSelect;
|
||
export type InsertUserOfflineSubscription = typeof userOfflineSubscriptions.$inferInsert;
|
||
|
||
// Form folders table
|
||
export const formFolders = pgTable("form_folders", {
|
||
id: serial("id").primaryKey(),
|
||
organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
|
||
name: varchar("name", { length: 200 }).notNull(),
|
||
parentId: integer("parent_id"),
|
||
position: integer("position").notNull().default(0),
|
||
createdAt: timestamp("created_at").defaultNow(),
|
||
updatedAt: timestamp("updated_at").defaultNow(),
|
||
}, (table) => ({
|
||
orgParentIdx: index("form_folders_org_parent_idx").on(table.organizationId, table.parentId),
|
||
positionIdx: index("form_folders_position_idx").on(table.position),
|
||
}));
|
||
|
||
export type FormFolder = typeof formFolders.$inferSelect;
|
||
export type InsertFormFolder = typeof formFolders.$inferInsert;
|
||
|
||
// Form tabs table
|
||
export const formTabs = pgTable("form_tabs", {
|
||
id: serial("id").primaryKey(),
|
||
formId: integer("form_id").notNull().references(() => forms.id, { onDelete: "cascade" }),
|
||
name: varchar("name", { length: 200 }).notNull(),
|
||
code: varchar("code", { length: 100 }).notNull(),
|
||
type: varchar("type", { length: 20 }).notNull().default("fields"), // 'fields', 'excel', or 'table'
|
||
position: integer("position").notNull().default(0),
|
||
displayOrder: integer("display_order").default(0), // визуальный порядок вкладок (DnD)
|
||
tableColumns: jsonb("table_columns"), // для type='table': [{id, name, type, width}]
|
||
createdAt: timestamp("created_at").defaultNow(),
|
||
}, (table) => ({
|
||
uniqueTabCode: unique().on(table.formId, table.code),
|
||
}));
|
||
|
||
// Form fields table
|
||
export const formFields = pgTable("form_fields", {
|
||
id: serial("id").primaryKey(),
|
||
formId: integer("form_id").notNull().references(() => forms.id, { onDelete: "cascade" }),
|
||
tabId: integer("tab_id").references(() => formTabs.id, { onDelete: "set null" }), // вкладка для поля
|
||
name: varchar("name", { length: 200 }).notNull(),
|
||
code: varchar("code", { length: 100 }).notNull(),
|
||
type: varchar("type", { length: 50 }).notNull(), // text, number, date, select, checkbox, toggle, button, user, file, textarea, rich-text, datetime, history-number, table, task, document_template
|
||
isRequired: boolean("is_required").default(false),
|
||
placeholder: text("placeholder"),
|
||
defaultValue: text("default_value"),
|
||
validationRules: jsonb("validation_rules"), // правила валидации в формате JSON
|
||
requiredRules: jsonb("required_rules"), // условная обязательность поля
|
||
immutableRules: jsonb("immutable_rules"), // условная неизменность поля (read-only)
|
||
visibilityRules: jsonb("visibility_rules"), // условия видимости поля
|
||
options: jsonb("options"), // опции для select полей
|
||
position: integer("position").notNull().default(0), // порядок создания (стабильный)
|
||
displayOrder: integer("display_order").default(0), // визуальный порядок (DnD)
|
||
linkedFormId: integer("linked_form_id").references(() => forms.id, { onDelete: "set null" }), // связанная форма для типа task
|
||
buttonActionType: varchar("button_action_type", { length: 20 }), // 'open_url', 'create_task' - для типа button
|
||
buttonUrl: text("button_url"), // URL для открытия (если buttonActionType = 'open_url')
|
||
buttonFormId: integer("button_form_id").references(() => forms.id, { onDelete: "set null" }), // форма для создания задачи (если buttonActionType = 'create_task')
|
||
companyAutofill: jsonb("company_autofill"), // настройки автозаполнения для типа company: { fullName: 'field_code', inn: 'field_code', ... }
|
||
taskRelationType: varchar("task_relation_type", { length: 10 }), // null | 'parent' | 'child' — характер связи для поля типа task
|
||
maxFileCount: integer("max_file_count"), // максимальное количество файлов для поля типа file (null = без ограничений)
|
||
maxFileSizeMB: integer("max_file_size_mb"), // максимальный суммарный размер файлов в МБ для поля типа file (null = без ограничений)
|
||
autoProlongation: boolean("auto_prolongation").default(false), // включено ли альтернативное значение (спецзначение) для date/datetime
|
||
autoProlongationLabel: text("auto_prolongation_label"), // кастомный текст спецзначения (null = "Автопролонгация")
|
||
linkToUserProfile: boolean("link_to_user_profile").default(false), // связывать задачи формы с профилем пользователя (поле типа user)
|
||
allowMultiple: boolean("allow_multiple").default(false), // разрешить множественный выбор для select/table
|
||
ganttRole: varchar("gantt_role", { length: 20 }), // 'start' | 'end' | 'milestone' | null — роль поля в Гантте
|
||
deletedAt: timestamp("deleted_at"), // мягкое удаление: скрыто везде, значения в задачах сохранены
|
||
createdAt: timestamp("created_at").defaultNow(),
|
||
}, (table) => ({
|
||
// Уникальность кода поля внутри формы — только среди активных (удалённые не мешают)
|
||
uniqueFieldCode: uniqueIndex("form_fields_form_id_code_active_key").on(table.formId, table.code).where(sql`${table.deletedAt} IS NULL`),
|
||
// Композитный уникальный ключ для referential integrity
|
||
fieldFormComposite: unique().on(table.id, table.formId),
|
||
}));
|
||
|
||
// Form statuses table
|
||
export const formStatuses = pgTable("form_statuses", {
|
||
id: serial("id").primaryKey(),
|
||
formId: integer("form_id").notNull().references(() => forms.id, { onDelete: "cascade" }),
|
||
name: varchar("name", { length: 100 }).notNull(),
|
||
color: varchar("color", { length: 7 }).notNull().default("#e5e7eb"),
|
||
position: integer("position").notNull().default(0),
|
||
isInitial: boolean("is_initial").default(false), // начальный статус
|
||
isFinal: boolean("is_final").default(false), // финальный статус
|
||
entryConditions: jsonb("entry_conditions"), // условия автоматического входа в статус
|
||
createdAt: timestamp("created_at").defaultNow(),
|
||
}, (table) => ({
|
||
// Композитный уникальный ключ для referential integrity
|
||
statusFormComposite: unique().on(table.id, table.formId),
|
||
// Только один initial статус на форму
|
||
oneInitialPerForm: uniqueIndex("one_initial_status_per_form").on(table.formId).where(sql`${table.isInitial} = true`),
|
||
}));
|
||
|
||
// Status transitions table
|
||
export const statusTransitions = pgTable("status_transitions", {
|
||
id: serial("id").primaryKey(),
|
||
formId: integer("form_id").notNull().references(() => forms.id, { onDelete: "cascade" }),
|
||
fromStatusId: integer("from_status_id").notNull().references(() => formStatuses.id),
|
||
toStatusId: integer("to_status_id").notNull().references(() => formStatuses.id), // статус при одобрении
|
||
rejectToStatusId: integer("reject_to_status_id").references(() => formStatuses.id), // статус при отклонении (опционально)
|
||
actionType: varchar("action_type", { length: 20 }).notNull().default("approve"), // 'approve', 'reject', 'custom' - для совместимости
|
||
actionName: varchar("action_name", { length: 100 }), // название кнопки действия
|
||
conditions: jsonb("conditions"), // условия для перехода
|
||
requiredRole: varchar("required_role", { length: 20 }), // роль, необходимая для действия
|
||
assigneeUserId: integer("assignee_user_id").references(() => users.id), // фиксированный согласующий
|
||
assigneeConditions: jsonb("assignee_conditions"), // условия для динамического назначения согласующего
|
||
approveButtonText: varchar("approve_button_text", { length: 50 }).default("Одобрить"),
|
||
approveButtonColor: varchar("approve_button_color", { length: 7 }).default("#22c55e"), // зеленый
|
||
rejectButtonText: varchar("reject_button_text", { length: 50 }).default("Отклонить"),
|
||
rejectButtonColor: varchar("reject_button_color", { length: 7 }).default("#ef4444"), // красный
|
||
createdAt: timestamp("created_at").defaultNow(),
|
||
}, (table) => ({
|
||
// Композитные foreign keys: статусы должны принадлежать одной форме
|
||
fromStatusSameForm: foreignKey({
|
||
columns: [table.fromStatusId, table.formId],
|
||
foreignColumns: [formStatuses.id, formStatuses.formId],
|
||
name: "fk_transition_from_status_same_form"
|
||
}),
|
||
toStatusSameForm: foreignKey({
|
||
columns: [table.toStatusId, table.formId],
|
||
foreignColumns: [formStatuses.id, formStatuses.formId],
|
||
name: "fk_transition_to_status_same_form"
|
||
}),
|
||
}));
|
||
|
||
// Tasks table
|
||
export const tasks = pgTable("tasks", {
|
||
id: serial("id").primaryKey(),
|
||
organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), // денормализовано для производительности индексов
|
||
formId: integer("form_id").notNull().references(() => forms.id),
|
||
title: varchar("title", { length: 500 }).notNull(),
|
||
description: text("description"),
|
||
currentStatusId: integer("current_status_id").notNull().references(() => formStatuses.id),
|
||
assignedTo: integer("assigned_to").references(() => users.id), // ответственный
|
||
createdBy: integer("created_by").notNull().references(() => users.id),
|
||
createdAt: timestamp("created_at").defaultNow(),
|
||
updatedAt: timestamp("updated_at").defaultNow(),
|
||
completedAt: timestamp("completed_at"),
|
||
isCompleted: boolean("is_completed").default(false), // флаг завершенности задачи
|
||
dueDate: timestamp("due_date"), // дедлайн
|
||
// Поля для подзадач
|
||
parentTaskId: integer("parent_task_id"), // ссылка на родительскую задачу (null для корневых)
|
||
depth: integer("depth").default(0), // глубина вложенности (0 для корневых)
|
||
position: integer("position").default(0), // позиция среди siblings
|
||
}, (table) => ({
|
||
// Композитный уникальный ключ для referential integrity
|
||
taskFormComposite: unique().on(table.id, table.formId),
|
||
// Композитный foreign key: статус должен принадлежать той же форме
|
||
statusSameForm: foreignKey({
|
||
columns: [table.currentStatusId, table.formId],
|
||
foreignColumns: [formStatuses.id, formStatuses.formId],
|
||
name: "fk_task_status_same_form"
|
||
}),
|
||
// Составные индексы по organization_id для tenant-уровневых выборок
|
||
orgFormIdx: index("tasks_org_id_form_id_idx").on(table.organizationId, table.formId),
|
||
orgStatusIdx: index("tasks_org_id_status_idx").on(table.organizationId, table.currentStatusId),
|
||
orgAssignedIdx: index("tasks_org_id_assigned_idx").on(table.organizationId, table.assignedTo),
|
||
// Индексы для подзадач и дедлайнов
|
||
parentTaskIdx: index("tasks_parent_task_id_idx").on(table.parentTaskId),
|
||
dueDateIdx: index("tasks_due_date_idx").on(table.dueDate),
|
||
}));
|
||
|
||
// Task relations table (many-to-many via form fields)
|
||
export const taskRelations = pgTable("task_relations", {
|
||
id: serial("id").primaryKey(),
|
||
parentTaskId: integer("parent_task_id").notNull().references(() => tasks.id, { onDelete: "cascade" }),
|
||
childTaskId: integer("child_task_id").notNull().references(() => tasks.id, { onDelete: "cascade" }),
|
||
fieldId: integer("field_id").references(() => formFields.id, { onDelete: "set null" }),
|
||
organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
|
||
createdAt: timestamp("created_at").defaultNow(),
|
||
}, (table) => ({
|
||
// Уникальная пара связанных задач: имя совпадает с существующим индексом в БД
|
||
uniqueRelation: unique("task_relations_parent_task_id_child_task_id_field_id_key").on(table.parentTaskId, table.childTaskId, table.fieldId),
|
||
}));
|
||
|
||
export const insertTaskRelationSchema = createInsertSchema(taskRelations).omit({
|
||
id: true,
|
||
createdAt: true,
|
||
});
|
||
|
||
export type TaskRelation = typeof taskRelations.$inferSelect;
|
||
export type InsertTaskRelation = z.infer<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', 'reminder'
|
||
mentionedUserIds: jsonb("mentioned_user_ids"), // массив ID упомянутых пользователей
|
||
botButtons: jsonb("bot_buttons"), // кнопки для бот-сообщений [{text, callbackData, style, row}]
|
||
visibleToUserIds: jsonb("visible_to_user_ids").$type<number[]>(), // NULL = видят все участники чата; массив = только перечисленные (+ автор)
|
||
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 insertUserFieldHistorySchema = createInsertSchema(userFieldHistory).omit({
|
||
id: true,
|
||
createdAt: true,
|
||
});
|
||
|
||
export const insertFormSchema = createInsertSchema(forms).omit({
|
||
id: true,
|
||
createdAt: true,
|
||
updatedAt: true,
|
||
});
|
||
|
||
export const updateFormSchema = createInsertSchema(forms).omit({
|
||
id: true,
|
||
organizationId: true,
|
||
createdBy: true,
|
||
createdAt: true,
|
||
updatedAt: true,
|
||
}).partial();
|
||
|
||
export const insertFormTabSchema = createInsertSchema(formTabs).omit({
|
||
id: true,
|
||
createdAt: true,
|
||
});
|
||
|
||
export const fieldRuleStatusModeSchema = z.enum([
|
||
"any",
|
||
"creation",
|
||
"in",
|
||
"in_or_creation",
|
||
]);
|
||
|
||
export const fieldRuleStatusConditionSchema = z.object({
|
||
mode: fieldRuleStatusModeSchema,
|
||
statusIds: z.array(z.number()).optional(),
|
||
});
|
||
|
||
export const fieldRuleSchema = z.object({
|
||
enabled: z.boolean().default(false),
|
||
statusCondition: fieldRuleStatusConditionSchema.optional().nullable(),
|
||
statusId: z.union([z.number(), z.literal("__creation__")]).nullable().optional(),
|
||
logic: z.enum(["and", "or"]).default("and"),
|
||
conditions: z.array(
|
||
z.object({
|
||
fieldCode: z.string(),
|
||
operator: z.enum(["contains", "empty", "filled"]).default("contains"),
|
||
value: z.string().optional(),
|
||
})
|
||
).default([]),
|
||
});
|
||
|
||
export const insertFormFieldSchema = createInsertSchema(formFields).omit({
|
||
id: true,
|
||
createdAt: true,
|
||
}).extend({
|
||
requiredRules: fieldRuleSchema.optional().nullable(),
|
||
immutableRules: fieldRuleSchema.optional().nullable(),
|
||
visibilityRules: fieldRuleSchema.optional().nullable(),
|
||
});
|
||
|
||
export const insertFormStatusSchema = createInsertSchema(formStatuses).omit({
|
||
id: true,
|
||
createdAt: true,
|
||
});
|
||
|
||
export const insertStatusTransitionSchema = createInsertSchema(statusTransitions).omit({
|
||
id: true,
|
||
createdAt: true,
|
||
});
|
||
|
||
export const insertTaskSchema = createInsertSchema(tasks).omit({
|
||
id: true,
|
||
createdAt: true,
|
||
updatedAt: true,
|
||
}).extend({
|
||
organizationId: z.number().optional(),
|
||
// Переопределяем dueDate чтобы принимать строку и конвертировать в Date
|
||
// Поддерживаем разные форматы: ISO, YYYY-MM-DD, DD.MM.YYYY
|
||
dueDate: z.union([z.string(), z.date(), z.null(), z.undefined()]).optional().transform(val => {
|
||
if (!val || val === '') return null;
|
||
if (val instanceof Date) return val;
|
||
// Попробуем распарсить как ISO или YYYY-MM-DD
|
||
const isoDate = new Date(val);
|
||
if (!isNaN(isoDate.getTime())) return isoDate;
|
||
// Попробуем DD.MM.YYYY формат
|
||
const parts = val.split('.');
|
||
if (parts.length === 3) {
|
||
const [day, month, year] = parts;
|
||
return new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
|
||
}
|
||
return null;
|
||
}),
|
||
completedAt: z.union([z.string(), z.date(), z.null(), z.undefined()]).optional().transform(val => {
|
||
if (!val || val === '') return null;
|
||
if (val instanceof Date) return val;
|
||
return new Date(val);
|
||
}),
|
||
customFields: z.record(z.any()).optional(),
|
||
});
|
||
|
||
export const insertTaskFieldValueSchema = createInsertSchema(taskFieldValues).omit({
|
||
id: true,
|
||
createdAt: true,
|
||
updatedAt: true,
|
||
});
|
||
|
||
export const insertFieldHistorySchema = createInsertSchema(fieldHistory).omit({
|
||
id: true,
|
||
changedAt: true,
|
||
});
|
||
|
||
export const insertTaskMessageSchema = createInsertSchema(taskMessages).omit({
|
||
id: true,
|
||
createdAt: true,
|
||
});
|
||
|
||
export const insertUserNotificationSchema = createInsertSchema(userNotifications).omit({
|
||
id: true,
|
||
createdAt: true,
|
||
});
|
||
|
||
export const insertMessageReadSchema = createInsertSchema(messageReads).omit({
|
||
id: true,
|
||
readAt: true,
|
||
});
|
||
|
||
// Registration schemas
|
||
export const registerOrganizationSchema = z.object({
|
||
organizationName: z.string().min(1, "Название организации обязательно"),
|
||
organizationSlug: z.string().min(1, "Код организации обязателен").regex(/^[a-z0-9-]+$/, "Код может содержать только латинские буквы, цифры и дефисы"),
|
||
adminEmail: z.string().email("Неверный формат email"),
|
||
adminFirstName: z.string().min(1, "Имя обязательно"),
|
||
adminLastName: z.string().min(1, "Фамилия обязательна"),
|
||
adminPassword: z.string().min(8, "Пароль должен содержать минимум 8 символов"),
|
||
});
|
||
|
||
export const loginSchema = z.object({
|
||
email: z.string().email("Неверный формат email"),
|
||
password: z.string().min(1, "Пароль обязателен"),
|
||
organizationSlug: z.string().optional(),
|
||
remember: z.boolean().default(false),
|
||
});
|
||
|
||
// Form creation schemas
|
||
export const createFormSchema = z.object({
|
||
name: z.string().min(1, "Название формы обязательно").max(200, "Название не может быть длиннее 200 символов"),
|
||
description: z.string().optional(),
|
||
fields: z.array(z.object({
|
||
name: z.string().min(1, "Название поля обязательно").max(200),
|
||
code: z.string().min(1, "Код поля обязателен").max(100).regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/, "Код поля может содержать только буквы, цифры и подчеркивания"),
|
||
type: z.enum(['text', 'number', 'date', 'select', 'checkbox', 'toggle', 'button', 'user', 'file', 'textarea', 'rich-text', 'datetime', 'history-number', 'document_template']),
|
||
isRequired: z.boolean().default(false),
|
||
placeholder: z.string().optional(),
|
||
defaultValue: z.string().optional(),
|
||
validationRules: z.record(z.any()).optional(),
|
||
options: z.array(z.object({
|
||
id: z.string(),
|
||
label: z.string(),
|
||
value: z.string().optional(),
|
||
})).optional(),
|
||
position: z.number().default(0),
|
||
})).default([]),
|
||
statuses: z.array(z.object({
|
||
name: z.string().min(1, "Название статуса обязательно").max(100),
|
||
color: z.string().regex(/^#[0-9a-fA-F]{6}$/, "Цвет должен быть в формате #RRGGBB").default("#e5e7eb"),
|
||
position: z.number().default(0),
|
||
isInitial: z.boolean().default(false),
|
||
isFinal: z.boolean().default(false),
|
||
entryConditions: z.array(z.object({
|
||
fieldCode: z.string(),
|
||
value: z.string(),
|
||
operator: z.enum(["contains", "empty", "filled"]).optional(),
|
||
})).optional(),
|
||
})).min(1, "Форма должна содержать минимум один статус"),
|
||
}).superRefine((data, ctx) => {
|
||
// Проверка уникальности кодов полей
|
||
const fieldCodes = data.fields.map(f => f.code);
|
||
const duplicatedCodes = fieldCodes.filter((code, index) => fieldCodes.indexOf(code) !== index);
|
||
if (duplicatedCodes.length > 0) {
|
||
ctx.addIssue({
|
||
code: z.ZodIssueCode.custom,
|
||
message: `Коды полей должны быть уникальными: ${Array.from(new Set(duplicatedCodes)).join(', ')}`,
|
||
path: ['fields']
|
||
});
|
||
}
|
||
|
||
// Проверка что есть ровно один начальный статус
|
||
const initialStatuses = data.statuses.filter(s => s.isInitial);
|
||
if (initialStatuses.length === 0) {
|
||
ctx.addIssue({
|
||
code: z.ZodIssueCode.custom,
|
||
message: "Должен быть определен один начальный статус",
|
||
path: ['statuses']
|
||
});
|
||
} else if (initialStatuses.length > 1) {
|
||
ctx.addIssue({
|
||
code: z.ZodIssueCode.custom,
|
||
message: "Может быть только один начальный статус",
|
||
path: ['statuses']
|
||
});
|
||
}
|
||
|
||
// Проверка что у select полей есть options
|
||
data.fields.forEach((field, index) => {
|
||
if (field.type === 'select' && (!field.options || field.options.length === 0)) {
|
||
ctx.addIssue({
|
||
code: z.ZodIssueCode.custom,
|
||
message: "Select поля должны содержать опции",
|
||
path: ['fields', index, 'options']
|
||
});
|
||
}
|
||
});
|
||
});
|
||
|
||
export const createTaskSchema = z.object({
|
||
formId: z.number().positive("ID формы обязателен"),
|
||
title: z.string().min(1, "Название задачи обязательно").max(500),
|
||
description: z.string().optional(),
|
||
assignedTo: z.number().optional(),
|
||
dueDate: z.string().datetime().optional(),
|
||
fieldValues: z.record(z.any()).optional(),
|
||
});
|
||
|
||
export const updateTaskSchema = z.object({
|
||
title: z.string().min(1).max(500).optional(),
|
||
description: z.string().optional(),
|
||
assignedTo: z.number().optional(),
|
||
dueDate: z.string().datetime().optional().nullable(),
|
||
fieldValues: z.record(z.any()).optional(),
|
||
});
|
||
|
||
export const transitionTaskSchema = z.object({
|
||
toStatusId: z.number().positive("ID статуса обязателен"),
|
||
comment: z.string().optional(),
|
||
fieldValues: z.record(z.any()).optional(),
|
||
});
|
||
|
||
export const chatAttachmentSchema = z.object({
|
||
url: z.string().refine(
|
||
(url) => url.startsWith('/uploads/') || url.startsWith('/api/files/') || /^https?:\/\//.test(url),
|
||
{ message: "Недопустимый URL вложения" }
|
||
),
|
||
name: z.string().max(255),
|
||
size: z.number().nonnegative(),
|
||
mimeType: z.string().max(100).optional(),
|
||
});
|
||
|
||
export const createTaskMessageSchema = z.object({
|
||
taskId: z.number().positive("ID задачи обязателен"),
|
||
replyToMessageId: z.number().int().positive().optional(),
|
||
message: z.string().max(10000, "Сообщение слишком длинное").default(""),
|
||
messageType: z.enum(['comment', 'system', 'status_change']).default('comment'),
|
||
mentionedUserIds: z.array(z.number().int().positive()).max(20).optional(),
|
||
attachments: z.array(chatAttachmentSchema).max(10).optional(),
|
||
});
|
||
|
||
export const searchUsersSchema = z.object({
|
||
query: z.string().min(1, "Поисковый запрос обязателен").max(50),
|
||
limit: z.number().min(1).max(20).default(10),
|
||
});
|
||
|
||
// Types
|
||
export type Organization = typeof organizations.$inferSelect;
|
||
export type User = typeof users.$inferSelect;
|
||
// Пользователь без чувствительных полей — безопасен для массовой выдачи клиенту
|
||
export type SafeUser = Omit<User, 'passwordHash' | 'verificationToken' | 'resetPasswordToken' | 'resetPasswordTokenHash' | 'resetPasswordExpires'>;
|
||
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 UserFieldHistory = typeof userFieldHistory.$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 InsertUserFieldHistory = z.infer<typeof insertUserFieldHistorySchema>;
|
||
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 };
|
||
|
||
// Отложенная смена статуса задачи (автоматизации: ctx.tasks.scheduleStatusChange).
|
||
// Воркер server/workers/delayed-status-change.worker.ts раз в 5 секунд
|
||
// подбирает просроченные строки и переводит задачу в target_status_id,
|
||
// только если она всё ещё в expected_status_id (иначе — отмена).
|
||
export const delayedStatusChanges = pgTable("delayed_status_changes", {
|
||
id: serial("id").primaryKey(),
|
||
organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
|
||
taskId: integer("task_id").notNull().references(() => tasks.id, { onDelete: "cascade" }),
|
||
formId: integer("form_id").notNull(),
|
||
expectedStatusId: integer("expected_status_id").notNull(),
|
||
targetStatusId: integer("target_status_id").notNull(),
|
||
runAt: timestamp("run_at").notNull(),
|
||
executedAt: timestamp("executed_at"),
|
||
createdAt: timestamp("created_at").defaultNow(),
|
||
}, (table) => ({
|
||
// Частичный индекс для воркера: ищет неисполненные, отсортированные по времени
|
||
runAtIdx: index("delayed_status_changes_run_at_idx").on(table.runAt).where(sql`${table.executedAt} IS NULL`),
|
||
}));
|
||
|
||
export type DelayedStatusChange = typeof delayedStatusChanges.$inferSelect;
|
||
export type InsertDelayedStatusChange = typeof delayedStatusChanges.$inferInsert;
|
||
|
||
// Task audit log table (history of changes)
|
||
export const taskAuditLog = pgTable("task_audit_log", {
|
||
id: serial("id").primaryKey(),
|
||
taskId: integer("task_id").notNull().references(() => tasks.id, { onDelete: "cascade" }),
|
||
organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
|
||
action: varchar("action", { length: 50 }).notNull(), // 'task.created', 'task.updated', 'status.changed', 'field.changed'
|
||
fieldId: integer("field_id"),
|
||
fieldName: text("field_name"),
|
||
oldValue: jsonb("old_value"),
|
||
newValue: jsonb("new_value"),
|
||
changedBy: integer("changed_by").references(() => users.id),
|
||
changedByName: text("changed_by_name"),
|
||
// Атрибуция действий бота (действие выполнено по API-ключу бота); NULL — действие пользователя
|
||
botId: integer("bot_id"),
|
||
metadata: jsonb("metadata"),
|
||
createdAt: timestamp("created_at").defaultNow(),
|
||
}, (table) => ({
|
||
// Быстрая выборка истории изменений задачи (убывающий createdAt для хронологии)
|
||
taskCreatedIdx: index("task_audit_log_task_created_idx").on(table.taskId, table.createdAt.desc()),
|
||
}));
|
||
|
||
export type TaskAuditLog = typeof taskAuditLog.$inferSelect;
|
||
export type InsertTaskAuditLog = typeof taskAuditLog.$inferInsert;
|
||
|
||
// =====================
|
||
// Task Tables (inline editable tables inside task tabs)
|
||
// =====================
|
||
|
||
export const taskTables = pgTable("task_tables", {
|
||
id: serial("id").primaryKey(),
|
||
taskId: integer("task_id").notNull().references(() => tasks.id, { onDelete: "cascade" }),
|
||
tabId: integer("tab_id").references(() => formTabs.id, { onDelete: "cascade" }), // nullable — существующие данные могут не иметь tab_id
|
||
name: varchar("name", { length: 200 }).notNull(),
|
||
columns: jsonb("columns"), // column definitions (JSON)
|
||
createdBy: integer("created_by").references(() => users.id),
|
||
createdAt: timestamp("created_at").defaultNow(),
|
||
updatedAt: timestamp("updated_at").defaultNow(),
|
||
});
|
||
|
||
export const taskTableCells = pgTable("task_table_cells", {
|
||
id: serial("id").primaryKey(),
|
||
tableId: integer("table_id").notNull().references(() => taskTables.id, { onDelete: "cascade" }),
|
||
rowIndex: integer("row_index").notNull(),
|
||
colIndex: integer("col_index").notNull(),
|
||
value: text("value"),
|
||
formula: text("formula"),
|
||
updatedBy: integer("updated_by").references(() => users.id),
|
||
updatedAt: timestamp("updated_at").defaultNow(),
|
||
}, (table) => ({
|
||
uniqueCell: unique("task_table_cells_table_id_row_index_col_index_key").on(table.tableId, table.rowIndex, table.colIndex),
|
||
}));
|
||
|
||
export const taskTableCellHistory = pgTable("task_table_cell_history", {
|
||
id: serial("id").primaryKey(),
|
||
cellId: integer("cell_id").notNull().references(() => taskTableCells.id, { onDelete: "cascade" }),
|
||
tableId: integer("table_id").notNull().references(() => taskTables.id, { onDelete: "cascade" }),
|
||
rowIndex: integer("row_index").notNull(),
|
||
colIndex: integer("col_index").notNull(),
|
||
prevValue: text("prev_value"),
|
||
prevFormula: text("prev_formula"),
|
||
newValue: text("new_value"),
|
||
newFormula: text("new_formula"),
|
||
changedBy: integer("changed_by").references(() => users.id),
|
||
changedAt: timestamp("changed_at").defaultNow(),
|
||
});
|
||
|
||
// =====================
|
||
// System Audit Log
|
||
// =====================
|
||
export const systemAuditLog = pgTable("system_audit_log", {
|
||
id: serial("id").primaryKey(),
|
||
action: varchar("action", { length: 100 }).notNull(),
|
||
userId: integer("user_id").references(() => users.id, { onDelete: "set null" }),
|
||
organizationId: integer("organization_id").references(() => organizations.id, { onDelete: "set null" }),
|
||
taskId: integer("task_id"),
|
||
details: jsonb("details"),
|
||
ip: varchar("ip", { length: 45 }),
|
||
userAgent: text("user_agent"),
|
||
createdAt: timestamp("created_at").defaultNow(),
|
||
}, (table) => ({
|
||
actionCreatedIdx: index("system_audit_log_action_created_idx").on(table.action, table.createdAt.desc()),
|
||
orgCreatedIdx: index("system_audit_log_org_created_idx").on(table.organizationId, table.createdAt.desc()),
|
||
}));
|
||
|
||
export type SystemAuditLog = typeof systemAuditLog.$inferSelect;
|
||
export type InsertSystemAuditLog = typeof systemAuditLog.$inferInsert;
|
||
|
||
// =====================
|
||
// Messenger: Conversations (direct & group chats)
|
||
// =====================
|
||
|
||
export const conversations = pgTable("conversations", {
|
||
id: serial("id").primaryKey(),
|
||
organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
|
||
type: varchar("type", { length: 20 }).notNull().default("direct"), // 'direct' | 'group' | 'bot_direct'
|
||
name: varchar("name", { length: 200 }), // only for group chats
|
||
createdBy: integer("created_by").notNull().references(() => users.id),
|
||
isDefault: boolean("is_default").default(false), // авто-создаваемая группа «Наша Фирма»
|
||
botId: integer("bot_id"), // for bot_direct conversations
|
||
createdAt: timestamp("created_at").defaultNow(),
|
||
updatedAt: timestamp("updated_at").defaultNow(),
|
||
}, (table) => ({
|
||
orgIdx: index("conversations_org_idx").on(table.organizationId),
|
||
}));
|
||
|
||
export const conversationMembers = pgTable("conversation_members", {
|
||
id: serial("id").primaryKey(),
|
||
conversationId: integer("conversation_id").notNull().references(() => conversations.id, { onDelete: "cascade" }),
|
||
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||
joinedAt: timestamp("joined_at").defaultNow(),
|
||
lastReadAt: timestamp("last_read_at"),
|
||
externalOrgId: integer("external_org_id"),
|
||
reactionUnreadCount: integer("reaction_unread_count").notNull().default(0),
|
||
mutedAt: timestamp("muted_at"),
|
||
}, (table) => ({
|
||
uniqueMember: unique("conversation_members_conv_user_key").on(table.conversationId, table.userId),
|
||
convIdx: index("conversation_members_conv_idx").on(table.conversationId),
|
||
userIdx: index("conversation_members_user_idx").on(table.userId),
|
||
}));
|
||
|
||
export const conversationMessages = pgTable("conversation_messages", {
|
||
id: serial("id").primaryKey(),
|
||
conversationId: integer("conversation_id").notNull().references(() => conversations.id, { onDelete: "cascade" }),
|
||
authorId: integer("author_id").references(() => users.id), // null for bot messages
|
||
botId: integer("bot_id"), // set for bot messages in bot_direct conversations
|
||
message: text("message").notNull().default(""),
|
||
replyToId: integer("reply_to_id"),
|
||
mentionedUserIds: jsonb("mentioned_user_ids").$type<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)
|
||
// Режим «потерян GPS»: трекер присылает stale re-send'ы, свежие точки считаются LBS
|
||
gpsLost: boolean("gps_lost").notNull().default(false),
|
||
// Последняя принятая точка — LBS (координаты сотовой вышки, не GPS)
|
||
lastIsLbs: boolean("last_is_lbs").notNull().default(false),
|
||
createdAt: timestamp("created_at").defaultNow(),
|
||
}, (table) => ({
|
||
orgIdx: index("gps_assets_org_idx").on(table.organizationId),
|
||
orgExternalIdx: uniqueIndex("gps_assets_org_external_idx").on(table.organizationId, table.externalId),
|
||
userIdx: index("gps_assets_user_idx").on(table.userId),
|
||
}));
|
||
|
||
export type GpsAsset = typeof gpsAssets.$inferSelect;
|
||
export type InsertGpsAsset = typeof gpsAssets.$inferInsert;
|
||
|
||
// История позиций (треки)
|
||
export const gpsPositions = pgTable("gps_positions", {
|
||
id: bigserial("id", { mode: "number" }).primaryKey(),
|
||
assetId: integer("asset_id").notNull().references(() => gpsAssets.id, { onDelete: "cascade" }),
|
||
organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
|
||
lat: doublePrecision("lat").notNull(),
|
||
lng: doublePrecision("lng").notNull(),
|
||
speed: doublePrecision("speed"),
|
||
course: doublePrecision("course"),
|
||
accuracy: doublePrecision("accuracy"),
|
||
// Точка — координаты сотовой вышки (LBS), а не GPS-фикс; сохраняется и
|
||
// отображается (пунктир), но не участвует в логике геозон
|
||
isLbs: boolean("is_lbs").notNull().default(false),
|
||
recordedAt: timestamp("recorded_at").notNull(),
|
||
createdAt: timestamp("created_at").defaultNow(),
|
||
}, (table) => ({
|
||
assetRecordedIdx: index("gps_positions_asset_recorded_idx").on(table.assetId, table.recordedAt),
|
||
orgRecordedIdx: index("gps_positions_org_recorded_idx").on(table.organizationId, table.recordedAt),
|
||
}));
|
||
|
||
export type GpsPosition = typeof gpsPositions.$inferSelect;
|
||
export type InsertGpsPosition = typeof gpsPositions.$inferInsert;
|
||
|
||
// Геозоны (полигоны; polygon — массив пар [lat, lng])
|
||
export const gpsGeozones = pgTable("gps_geozones", {
|
||
id: serial("id").primaryKey(),
|
||
organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
|
||
name: text("name").notNull(),
|
||
polygon: jsonb("polygon").$type<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),
|
||
// Порог «далеко от дороги» (м) для фильтра LBS-точек в режиме потери GPS; 0 = фильтр выключен
|
||
offRoadMeters: integer("off_road_meters").notNull().default(200),
|
||
defaultLat: doublePrecision("default_lat"),
|
||
defaultLng: doublePrecision("default_lng"),
|
||
defaultZoom: integer("default_zoom"),
|
||
allowedUserIds: jsonb("allowed_user_ids").$type<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;
|