feat: MedSchedule модуль — семейный медицинский помощник с Telegram/MAX ботом, n8n OCR и /med UI

This commit is contained in:
2026-07-15 09:42:55 +03:00
parent d35d0cd1ef
commit f519ce95db
15 changed files with 2960 additions and 2 deletions

View File

@@ -1,5 +1,5 @@
import { sql, relations } from "drizzle-orm";
import { pgTable, text, varchar, integer, boolean, timestamp, jsonb, serial, unique, foreignKey, uniqueIndex, index, numeric, date, uuid, type AnyPgColumn, customType } from "drizzle-orm/pg-core";
import { pgTable, text, varchar, integer, boolean, timestamp, jsonb, serial, 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";
@@ -3623,3 +3623,199 @@ export const FINANCE_COLORS = [
"#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;