diff --git a/.env.example b/.env.example index 841d4b8..7d99dfe 100644 --- a/.env.example +++ b/.env.example @@ -103,6 +103,25 @@ DATABASE_URL=postgresql://user:password@host/dbname?sslmode=require # Public URL of CRM itself — used for presigned document URLs # APP_URL=https://iistwin.ru +# ============================================= +# MedSchedule bot settings (optional) +# ============================================= +# Telegram bot token for MedSchedule personal/family health assistant +# TELEGRAM_BOT_TOKEN=your-telegram-bot-token +# Secret token for validating Telegram webhook requests +# TELEGRAM_BOT_WEBHOOK_SECRET=your-telegram-webhook-secret + +# MAX bot token and base URL (optional) +# MAX_BOT_TOKEN=your-max-bot-token +# MAX_BOT_API_BASE=https://api.max.ru/bot +# Secret token for validating MAX webhook requests +# MAX_BOT_WEBHOOK_SECRET=your-max-webhook-secret + +# n8n webhook URL for MedSchedule OCR (medical scan analysis) +# IMPORTANT: iistwin must reach n8n via the internal Docker network URL. +# External /webhook and /n8n/webhook paths are NOT exposed for webhooks. +# MED_N8N_OCR_WEBHOOK_URL=http://n8n:5678/webhook/972a1fce-c84c-49f6-bc04-7bd838b61acc/med-ocr + # ============================================= # Document Worker (optional — for PDF/DOCX generation) # ============================================= diff --git a/client/src/App.tsx b/client/src/App.tsx index e6d2e8f..6622591 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -77,6 +77,7 @@ const HomePage = safeLazy(() => import("@/pages/Home")); const DashboardPage = safeLazy(() => import("@/pages/DashboardPage")); const CalendarPage = safeLazy(() => import("@/pages/CalendarPage")); const GanttPage = safeLazy(() => import("@/pages/GanttPage")); +const MedSchedulePage = safeLazy(() => import("@/pages/medschedule")); const ShareTarget = safeLazy(() => import("@/pages/ShareTarget")); const QrHandler = safeLazy(() => import("@/pages/QrHandler")); const NotFound = safeLazy(() => import("@/pages/not-found")); @@ -127,6 +128,8 @@ function Router() { + + diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index 9c43bb9..dedcede 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -58,6 +58,7 @@ import { PiggyBank, LayoutDashboard, ChartGantt, + HeartPulse, } from 'lucide-react'; import { useAuth } from '@/hooks/useAuth'; import { useTheme } from '@/hooks/use-theme'; @@ -1202,6 +1203,46 @@ function GanttNavItem({ collapsed }: { collapsed: boolean }) { ); } +function MedScheduleNavItem({ collapsed }: { collapsed: boolean }) { + const { user } = useAuth(); + const [location] = useLocation(); + const isActive = location === '/med' || location.startsWith('/med/'); + + if (user?.organization?.slug !== 'medschedule') return null; + + if (collapsed) { + return ( + + + +
+ +
+ +
+ + MedSchedule + +
+ ); + } + + return ( + +
+ + MedSchedule +
+ + ); +} + function HomeNavItem({ collapsed }: { collapsed: boolean }) { const [location] = useLocation(); const isActive = location === '/home'; @@ -1385,6 +1426,7 @@ export function Sidebar({ children }: SidebarProps) { + diff --git a/client/src/pages/medschedule/index.tsx b/client/src/pages/medschedule/index.tsx new file mode 100644 index 0000000..94f1467 --- /dev/null +++ b/client/src/pages/medschedule/index.tsx @@ -0,0 +1,302 @@ +import { useState, useEffect } from "react"; +import { useLocation } from "wouter"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Badge } from "@/components/ui/badge"; +import { Plus, Users, Pill, ClipboardList, Stethoscope, MessageCircle } from "lucide-react"; +import Layout from "@/components/Layout"; +import { useToast } from "@/hooks/use-toast"; +import { apiRequest } from "@/lib/queryClient"; + +const TABS = [ + { value: "schedule", label: "Расписание", icon: Pill }, + { value: "family", label: "Семья", icon: Users }, + { value: "cabinet", label: "Аптечка", icon: ClipboardList }, + { value: "records", label: "Обследования", icon: Stethoscope }, + { value: "checkins", label: "Чек-ины", icon: MessageCircle }, +]; + +export default function MedSchedulePage() { + const [location, setLocation] = useLocation(); + const { toast } = useToast(); + const queryClient = useQueryClient(); + const match = location.match(/\/med\/(.*)/); + const activeTab = match?.[1] || "schedule"; + + const { data: familyData } = useQuery({ + queryKey: ["/api/med/family"], + retry: false, + }); + const families: any[] = familyData?.groups || []; + const family = families[0]; + + const onTabChange = (value: string) => setLocation(`/med/${value}`); + + return ( + +
+
+

MedSchedule

+ {family && {family.name}} +
+ + {!family && activeTab !== "family" ? ( + + +

Сначала создайте или вступите в семейную группу.

+ +
+
+ ) : ( + + + {TABS.map((t) => ( + + + {t.label} + + ))} + + + + + + + + + + + + + + + + + + + )} +
+
+ ); +} + +function ScheduleTab({ family }: { family?: any }) { + const [range, setRange] = useState<{ from: string; to: string }>(() => { + const now = new Date(); + const from = new Date(now.getFullYear(), now.getMonth(), now.getDate()).toISOString(); + const to = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 7).toISOString(); + return { from, to }; + }); + + const { data } = useQuery({ + queryKey: ["/api/med/calendar", family?.id, range.from, range.to], + enabled: !!family, + }); + const events = data?.events || []; + + return ( +
+ + + Расписание приёма лекарств + + + {events.length === 0 ? ( +

Нет запланированных приёмов. Добавьте лекарство через бота: /add название доза время

+ ) : ( +
+ {events.map((e: any) => ( +
+
+
{e.title}
+
{e.memberName} — {new Date(e.scheduledAt).toLocaleString("ru-RU")}
+
+ {e.status} +
+ ))} +
+ )} +
+
+
+ ); +} + +function FamilyTab() { + const queryClient = useQueryClient(); + const { toast } = useToast(); + const [newFamilyName, setNewFamilyName] = useState(""); + const [inviteCode, setInviteCode] = useState(""); + const [memberName, setMemberName] = useState(""); + + const { data: familyData, refetch } = useQuery({ queryKey: ["/api/med/family"] }); + const families: any[] = familyData?.groups || []; + const family = families[0]; + + const createFamily = useMutation({ + mutationFn: (name: string) => apiRequest("POST", "/api/med/family", { name }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["/api/med/family"] }); + setNewFamilyName(""); + toast({ title: "Семья создана" }); + }, + }); + + const joinFamily = useMutation({ + mutationFn: (code: string) => apiRequest("POST", "/api/med/family/join", { inviteCode: code }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["/api/med/family"] }); + setInviteCode(""); + toast({ title: "Вы вступили в семью" }); + }, + }); + + const addMember = useMutation({ + mutationFn: (name: string) => apiRequest("POST", `/api/med/family/${family.id}/members`, { profileName: name }), + onSuccess: () => { + refetch(); + setMemberName(""); + toast({ title: "Участник добавлен" }); + }, + }); + + return ( +
+ {!family ? ( + + Создать или вступить в семью + +
+ setNewFamilyName(e.target.value)} /> + +
+
+ setInviteCode(e.target.value)} /> + +
+
+
+ ) : ( + <> + + + {family.name} + + +

Код приглашения: {family.inviteCode}

+
+ setMemberName(e.target.value)} /> + +
+
+
+ + + )} +
+ ); +} + +function FamilyMembers({ familyId }: { familyId: number }) { + const { data } = useQuery({ queryKey: [`/api/med/family/${familyId}`] }); + const members = data?.members || []; + return ( + + Участники + + {members.length === 0 ? ( +

Нет участников.

+ ) : ( +
+ {members.map((m: any) => ( +
+
+
{m.profileName}
+
{m.role} {m.telegramChatId || m.maxChatId ? "· бот подключён" : ""}
+
+
+ ))} +
+ )} +
+
+ ); +} + +function CabinetTab({ family }: { family?: any }) { + const { data, refetch } = useQuery({ queryKey: ["/api/med/cabinet", family?.id], enabled: !!family }); + const items = data?.items || []; + return ( + + Аптечка + + {items.length === 0 ? ( +

Аптечка пуста. Добавьте покупку через бота: /buy название количество

+ ) : ( +
+ {items.map((i: any) => ( +
+
+
{i.name}
+
{i.quantity} {i.unit}{i.expiryDate ? ` · годен до ${i.expiryDate}` : ""}
+
+
+ ))} +
+ )} +
+
+ ); +} + +function RecordsTab({ family }: { family?: any }) { + const { data } = useQuery({ queryKey: ["/api/med/records", family?.id], enabled: !!family }); + const records = data?.records || []; + return ( +
+ {records.map((r: any) => ( + + + {r.title} + + + {r.aiSummary &&

{r.aiSummary}

} + {r.rawText &&

{r.rawText}

} +
+
+ ))} + {records.length === 0 &&

Нет записей. Добавьте через бота: /record описание обследования

} +
+ ); +} + +function CheckinsTab({ family }: { family?: any }) { + const { data } = useQuery({ queryKey: ["/api/med/checkins", family?.id], enabled: !!family }); + const checkins = data?.checkins || []; + return ( +
+ {checkins.map((c: any) => ( + + + {c.checkInDate} + + +

{c.question}

+ {c.answerText ? ( + <> +

{c.answerText}

+ {c.aiAlert && {c.aiAlert}} + + ) : ( +

Ожидается ответ через бота: /checkin ваш ответ

+ )} +
+
+ ))} + {checkins.length === 0 &&

Нет активных чек-инов. Они создаются автоматически каждое утро.

} +
+ ); +} diff --git a/migrations/0059_medschedule_tables.sql b/migrations/0059_medschedule_tables.sql new file mode 100644 index 0000000..f5866cb --- /dev/null +++ b/migrations/0059_medschedule_tables.sql @@ -0,0 +1,150 @@ +-- MedSchedule — семейное медицинское расписание + +CREATE TABLE IF NOT EXISTS med_family_groups ( + id SERIAL PRIMARY KEY, + organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + name TEXT NOT NULL, + invite_code TEXT NOT NULL UNIQUE, + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMP DEFAULT NOW() NOT NULL, + updated_at TIMESTAMP DEFAULT NOW() NOT NULL +); +CREATE INDEX IF NOT EXISTS med_family_groups_org_idx ON med_family_groups(organization_id); +CREATE UNIQUE INDEX IF NOT EXISTS med_family_groups_invite_idx ON med_family_groups(invite_code); + +CREATE TABLE IF NOT EXISTS med_family_members ( + id SERIAL PRIMARY KEY, + family_id INTEGER NOT NULL REFERENCES med_family_groups(id) ON DELETE CASCADE, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + profile_name TEXT NOT NULL, + profile_type TEXT NOT NULL DEFAULT 'person', + date_of_birth DATE, + gender TEXT, + notes TEXT, + telegram_chat_id TEXT, + max_chat_id TEXT, + role TEXT NOT NULL DEFAULT 'member', + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMP DEFAULT NOW() NOT NULL, + updated_at TIMESTAMP DEFAULT NOW() NOT NULL +); +CREATE INDEX IF NOT EXISTS med_family_members_family_idx ON med_family_members(family_id); +CREATE INDEX IF NOT EXISTS med_family_members_user_idx ON med_family_members(user_id); + +CREATE TABLE IF NOT EXISTS med_medications ( + id SERIAL PRIMARY KEY, + family_id INTEGER NOT NULL REFERENCES med_family_groups(id) ON DELETE CASCADE, + member_id INTEGER NOT NULL REFERENCES med_family_members(id) ON DELETE CASCADE, + name TEXT NOT NULL, + dosage TEXT, + form TEXT, + schedule JSONB NOT NULL, + instructions TEXT, + color TEXT DEFAULT '#4CAF50', + is_active BOOLEAN DEFAULT TRUE, + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMP DEFAULT NOW() NOT NULL, + updated_at TIMESTAMP DEFAULT NOW() NOT NULL +); +CREATE INDEX IF NOT EXISTS med_medications_family_idx ON med_medications(family_id); +CREATE INDEX IF NOT EXISTS med_medications_member_idx ON med_medications(member_id); + +CREATE TABLE IF NOT EXISTS med_intake_logs ( + id SERIAL PRIMARY KEY, + medication_id INTEGER NOT NULL REFERENCES med_medications(id) ON DELETE CASCADE, + member_id INTEGER NOT NULL REFERENCES med_family_members(id) ON DELETE CASCADE, + scheduled_at TIMESTAMP NOT NULL, + taken_at TIMESTAMP, + status TEXT NOT NULL DEFAULT 'pending', + note TEXT, + reminded_via TEXT[] DEFAULT '{}', + created_at TIMESTAMP DEFAULT NOW() NOT NULL, + updated_at TIMESTAMP DEFAULT NOW() NOT NULL +); +CREATE INDEX IF NOT EXISTS med_intake_logs_medication_idx ON med_intake_logs(medication_id); +CREATE INDEX IF NOT EXISTS med_intake_logs_member_idx ON med_intake_logs(member_id); +CREATE INDEX IF NOT EXISTS med_intake_logs_scheduled_idx ON med_intake_logs(scheduled_at); +CREATE INDEX IF NOT EXISTS med_intake_logs_status_idx ON med_intake_logs(status); + +CREATE TABLE IF NOT EXISTS med_cabinet_items ( + id SERIAL PRIMARY KEY, + family_id INTEGER NOT NULL REFERENCES med_family_groups(id) ON DELETE CASCADE, + name TEXT NOT NULL, + form TEXT, + dosage TEXT, + quantity REAL DEFAULT 0, + unit TEXT DEFAULT 'шт', + expiry_date DATE, + indication TEXT, + location TEXT, + photo_url TEXT, + added_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMP DEFAULT NOW() NOT NULL, + updated_at TIMESTAMP DEFAULT NOW() NOT NULL +); +CREATE INDEX IF NOT EXISTS med_cabinet_items_family_idx ON med_cabinet_items(family_id); +CREATE INDEX IF NOT EXISTS med_cabinet_items_expiry_idx ON med_cabinet_items(expiry_date); + +CREATE TABLE IF NOT EXISTS med_cabinet_stock ( + id SERIAL PRIMARY KEY, + cabinet_item_id INTEGER NOT NULL REFERENCES med_cabinet_items(id) ON DELETE CASCADE, + change REAL NOT NULL, + reason TEXT, + note TEXT, + changed_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMP DEFAULT NOW() NOT NULL +); +CREATE INDEX IF NOT EXISTS med_cabinet_stock_item_idx ON med_cabinet_stock(cabinet_item_id); + +CREATE TABLE IF NOT EXISTS med_medical_records ( + id SERIAL PRIMARY KEY, + family_id INTEGER NOT NULL REFERENCES med_family_groups(id) ON DELETE CASCADE, + member_id INTEGER NOT NULL REFERENCES med_family_members(id) ON DELETE CASCADE, + title TEXT NOT NULL, + record_type TEXT NOT NULL, + record_date DATE, + doctor_name TEXT, + clinic_name TEXT, + raw_text TEXT, + ai_summary TEXT, + ai_findings JSONB, + file_urls TEXT[] DEFAULT '{}', + added_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMP DEFAULT NOW() NOT NULL, + updated_at TIMESTAMP DEFAULT NOW() NOT NULL +); +CREATE INDEX IF NOT EXISTS med_medical_records_family_idx ON med_medical_records(family_id); +CREATE INDEX IF NOT EXISTS med_medical_records_member_idx ON med_medical_records(member_id); +CREATE INDEX IF NOT EXISTS med_medical_records_date_idx ON med_medical_records(record_date); + +CREATE TABLE IF NOT EXISTS med_daily_checkins ( + id SERIAL PRIMARY KEY, + family_id INTEGER NOT NULL REFERENCES med_family_groups(id) ON DELETE CASCADE, + member_id INTEGER REFERENCES med_family_members(id) ON DELETE CASCADE, + check_in_date DATE NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + question TEXT NOT NULL, + answer_text TEXT, + ai_summary TEXT, + ai_alert TEXT, + created_at TIMESTAMP DEFAULT NOW() NOT NULL, + updated_at TIMESTAMP DEFAULT NOW() NOT NULL +); +CREATE INDEX IF NOT EXISTS med_daily_checkins_family_idx ON med_daily_checkins(family_id); +CREATE INDEX IF NOT EXISTS med_daily_checkins_member_idx ON med_daily_checkins(member_id); +CREATE INDEX IF NOT EXISTS med_daily_checkins_date_idx ON med_daily_checkins(check_in_date); + +CREATE TABLE IF NOT EXISTS med_bot_bindings ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + telegram_chat_id TEXT, + max_chat_id TEXT, + telegram_username TEXT, + max_username TEXT, + created_at TIMESTAMP DEFAULT NOW() NOT NULL, + updated_at TIMESTAMP DEFAULT NOW() NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS med_bot_bindings_user_org_idx ON med_bot_bindings(user_id, organization_id); +CREATE UNIQUE INDEX IF NOT EXISTS med_bot_bindings_telegram_idx ON med_bot_bindings(organization_id, telegram_chat_id) WHERE telegram_chat_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS med_bot_bindings_max_idx ON med_bot_bindings(organization_id, max_chat_id) WHERE max_chat_id IS NOT NULL; diff --git a/server/index.ts b/server/index.ts index 5190dd0..fdb1e22 100644 --- a/server/index.ts +++ b/server/index.ts @@ -6,6 +6,7 @@ import { setupVite, serveStatic, log } from "./vite"; import { pushService } from "./services/push.service"; import { notificationService } from "./services/notification.service"; import { webPushService } from "./services/web-push.service"; +import { startMedScheduleWorker } from "./medschedule/worker"; import { storage } from "./storage"; import type { ReminderRecipient } from "@shared/schema"; import { db, withSuperAdmin } from "./db"; // lazy proxy — safe at module load; throws on first use if DATABASE_URL missing @@ -1020,6 +1021,10 @@ async function runStartupDataPatches() { pushService.startQueueProcessor(5000); log('Push notification worker started (Pushy)'); + // Start MedSchedule worker (medication reminders, daily check-ins, expiry alerts) + startMedScheduleWorker(); + log('MedSchedule worker started'); + // Clean up duplicate subscriptions then backfill defaults for existing users (async () => { try { diff --git a/server/medschedule/ai.service.ts b/server/medschedule/ai.service.ts new file mode 100644 index 0000000..fe3c9a6 --- /dev/null +++ b/server/medschedule/ai.service.ts @@ -0,0 +1,289 @@ +import { generateSummary } from "../services/llm-provider"; +import { resolveChatConfig } from "../services/embedding.service"; +import type { MedFamilyMember, MedMedication, MedMedicalRecord } from "@shared/schema"; + +export interface ParsedMedication { + name: string; + dosage?: string; + form?: string; + schedule: { + type: "daily" | "weekly" | "interval" | "as_needed"; + times: string[]; + days?: number[]; + intervalHours?: number; + startDate: string; + endDate?: string; + }; + instructions?: string; +} + +export interface ParsedPurchase { + name: string; + dosage?: string; + form?: string; + quantity?: number; + unit?: string; + indication?: string; +} + +export interface ParsedMedicalRecord { + title: string; + recordType: string; + summary: string; + findings: Array<{ name: string; value: string; unit?: string; isNormal?: boolean | null; referenceRange?: string | null }>; + diagnoses?: string[]; + recommendations?: string[]; + recordDate?: string | null; + confidence?: number | null; + rawText?: string; +} + +export interface N8nOcrResult { + title: string; + recordType: string; + summary: string; + findings: Array<{ name: string; value: string; unit?: string; isNormal?: boolean | null; referenceRange?: string | null }>; + diagnoses?: string[]; + recommendations?: string[]; + recordDate?: string | null; + confidence?: number | null; + rawText?: string; +} + +export interface DailyQuestionResult { + question: string; + contextHint?: string; +} + +export interface CheckinSummaryResult { + summary: string; + alert?: string; +} + +async function chat(organizationId: number, prompt: string): Promise { + try { + const config = await resolveChatConfig(organizationId); + if (!config.summarizationEnabled) return null; + if (config.provider !== "ollama" && !config.apiKey) return null; + return generateSummary(prompt, config); + } catch (err) { + console.error("[MedSchedule AI] chat error:", err); + return null; + } +} + +export async function analyzeMedicalImageWithN8n(fileUrl: string): Promise { + const webhookUrl = process.env.MED_N8N_OCR_WEBHOOK_URL; + if (!webhookUrl) { + console.error("[MedSchedule AI] MED_N8N_OCR_WEBHOOK_URL is not set"); + return null; + } + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 30_000); + + const res = await fetch(webhookUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fileUrl, language: "ru" }), + signal: controller.signal, + }); + + clearTimeout(timeout); + + if (!res.ok) { + console.error("[MedSchedule AI] n8n OCR webhook error:", res.status, await res.text()); + return null; + } + + const data = (await res.json()) as N8nOcrResult | { error?: string }; + if ("error" in data && data.error) { + console.error("[MedSchedule AI] n8n OCR returned error:", data.error); + return null; + } + + return data as N8nOcrResult; + } catch (err) { + console.error("[MedSchedule AI] n8n OCR request error:", err); + return null; + } +} + +export async function parseMedicationFromText( + organizationId: number, + text: string +): Promise { + const prompt = `Извлеки из текста информацию о лекарстве и верни ТОЛЬКО JSON объект без markdown: +{ + "name": "название лекарства", + "dosage": "дозировка", + "form": "форма (таблетка, капсула, мл и т.д.)", + "schedule": { + "type": "daily|weekly|interval|as_needed", + "times": ["08:00", "20:00"], + "days": [1,3,5], + "intervalHours": 8, + "startDate": "2026-07-14", + "endDate": "2026-07-28" + }, + "instructions": "инструкции" +} + +days — дни недели: 0=вс, 1=пн, 2=вт, 3=ср, 4=чт, 5=пт, 6=сб. Используй только для weekly. +intervalHours — только для interval. +Если время не указано, используй ["08:00"]. + +Текст: """${text}"""`; + + const raw = await chat(organizationId, prompt); + if (!raw) return null; + return safeJsonParse(raw); +} + +export async function parsePurchaseFromText( + organizationId: number, + text: string +): Promise { + const prompt = `Извлеки из текста (OCR с фото лекарства) информацию и верни ТОЛЬКО JSON объект без markdown: +{ + "name": "название лекарства", + "dosage": "дозировка", + "form": "форма", + "quantity": 30, + "unit": "шт/таб/уп", + "indication": "для чего назначено" +} + +Если количество не указано, quantity = 1, unit = "шт". + +Текст: """${text}"""`; + + const raw = await chat(organizationId, prompt); + if (!raw) return null; + return safeJsonParse(raw); +} + +export async function parseMedicalRecordFromText( + organizationId: number, + text: string +): Promise { + const prompt = `Ты медицинский ассистент. Проанализируй текст медицинского обследования/анализа и верни ТОЛЬКО JSON объект без markdown: +{ + "title": "краткое название обследования", + "recordType": "lab_test|imaging|doctor_note|prescription|other", + "summary": "краткое резюме для неспециалиста", + "findings": [ + { "name": "название показателя", "value": "значение", "unit": "единица измерения", "isNormal": true, "referenceRange": "референсные значения" } + ], + "diagnoses": ["список диагнозов, если указаны"], + "recommendations": ["рекомендации врача, если указаны"], + "recordDate": "YYYY-MM-DD или null", + "confidence": 0.95, + "rawText": "исходный текст" +} + +Если дата не указана, используй null. Если референсный диапазон не указан, используй null. + +Текст: """${text}"""`; + + const raw = await chat(organizationId, prompt); + if (!raw) return null; + return safeJsonParse(raw); +} + +export async function generateDailyCheckinQuestion( + organizationId: number, + members: MedFamilyMember[], + medications: MedMedication[], + recentRecords: MedMedicalRecord[] +): Promise { + const memberNames = members.map((m) => m.profileName).join(", "); + const medList = medications + .filter((m) => m.isActive) + .map((m) => `- ${m.name} (${m.dosage || "без дозировки"}) — ${members.find((x) => x.id === m.memberId)?.profileName || "неизвестно"}`) + .join("\n"); + const recordSummary = recentRecords + .slice(0, 3) + .map((r) => `- ${r.title}: ${r.aiSummary || r.rawText?.slice(0, 100) || ""}`) + .join("\n"); + + const prompt = `Ты заботливый семейный медицинский ассистент. Каждый день ты спрашиваешь у семьи, как дела. + +Члены семьи: ${memberNames || "семья"}. + +Активные лекарства: +${medList || "нет"} + +Недавние обследования: +${recordSummary || "нет"} + +Сформулируй один короткий, дружелюбный вопрос на русском языке, на который семья может ответить текстом. Вопрос должен учитывать лекарства и обследования, если они есть. Не диагностируй, не назначай лечение. + +Верни ТОЛЬКО JSON без markdown: +{ + "question": "текст вопроса", + "contextHint": "почему именно такой вопрос (для админа)" +}`; + + const raw = await chat(organizationId, prompt); + if (!raw) return { question: "Как вы себя чувствуете сегодня? Есть ли изменения в самочувствии?" }; + const parsed = safeJsonParse(raw); + if (!parsed?.question) { + return { question: "Как вы себя чувствуете сегодня? Есть ли изменения в самочувствии?" }; + } + return parsed; +} + +export async function summarizeCheckinAnswer( + organizationId: number, + question: string, + answer: string, + members: MedFamilyMember[], + medications: MedMedication[], + recentRecords: MedMedicalRecord[] +): Promise { + const memberNames = members.map((m) => m.profileName).join(", "); + const medList = medications + .filter((m) => m.isActive) + .map((m) => `- ${m.name} (${m.dosage || "без дозировки"})`) + .join("\n"); + + const prompt = `Ты медицинский ассистент. Проанализируй ответ семьи на ежедневный вопрос. + +Вопрос: """${question}""" +Ответ: """${answer}""" + +Члены семьи: ${memberNames || "семья"}. +Активные лекарства: +${medList || "нет"} + +Верни ТОЛЬКО JSON без markdown: +{ + "summary": "краткое резюме ответа", + "alert": "если есть тревожные сигналы (сильное ухудшение, побочные эффекты, пропуск важных лекарств) — опиши кратко. Иначе пустая строка." +} + +Не ставь диагнозов. Рекомендуй обратиться к врачу при тревожных симптомах.`; + + const raw = await chat(organizationId, prompt); + if (!raw) return null; + const parsed = safeJsonParse(raw); + if (!parsed) return null; + return { + summary: parsed.summary || answer.slice(0, 200), + alert: parsed.alert || undefined, + }; +} + +function safeJsonParse(text: string): T | null { + try { + const cleaned = text + .replace(/^```json\s*/, "") + .replace(/```\s*$/, "") + .trim(); + return JSON.parse(cleaned) as T; + } catch { + return null; + } +} diff --git a/server/medschedule/bot-sender.ts b/server/medschedule/bot-sender.ts new file mode 100644 index 0000000..6c47051 --- /dev/null +++ b/server/medschedule/bot-sender.ts @@ -0,0 +1,90 @@ +export type BotPlatform = "telegram" | "max"; + +const TELEGRAM_API_BASE = "https://api.telegram.org/bot"; +const MAX_API_BASE = process.env.MAX_BOT_API_BASE || "https://api.max.ru/bot"; + +export async function sendBotMessage( + platform: BotPlatform, + chatId: string, + text: string, + options?: { + parseMode?: "HTML" | "Markdown"; + replyMarkup?: any; + token?: string; + } +): Promise<{ ok: boolean; error?: string }> { + const token = options?.token || (platform === "telegram" ? process.env.TELEGRAM_BOT_TOKEN : process.env.MAX_BOT_TOKEN); + if (!token) { + return { ok: false, error: `Token not configured for ${platform}` }; + } + + const base = platform === "telegram" ? `${TELEGRAM_API_BASE}${token}` : `${MAX_API_BASE}${token}`; + const url = `${base}/sendMessage`; + + try { + const body: Record = { + chat_id: chatId, + text, + }; + if (options?.parseMode) body.parse_mode = options.parseMode; + if (options?.replyMarkup) body.reply_markup = options.replyMarkup; + + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errText = await response.text(); + console.error(`[${platform}] sendMessage error ${response.status}:`, errText); + return { ok: false, error: errText }; + } + + return { ok: true }; + } catch (err: any) { + console.error(`[${platform}] sendMessage failed:`, err); + return { ok: false, error: err.message || "Request failed" }; + } +} + +export async function sendBotPhoto( + platform: BotPlatform, + chatId: string, + photoUrl: string, + caption?: string, + options?: { token?: string } +): Promise<{ ok: boolean; error?: string }> { + const token = options?.token || (platform === "telegram" ? process.env.TELEGRAM_BOT_TOKEN : process.env.MAX_BOT_TOKEN); + if (!token) { + return { ok: false, error: `Token not configured for ${platform}` }; + } + + const base = platform === "telegram" ? `${TELEGRAM_API_BASE}${token}` : `${MAX_API_BASE}${token}`; + const url = `${base}/sendPhoto`; + + try { + const body: Record = { + chat_id: chatId, + photo: photoUrl, + }; + if (caption) body.caption = caption; + + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errText = await response.text(); + console.error(`[${platform}] sendPhoto error ${response.status}:`, errText); + return { ok: false, error: errText }; + } + + return { ok: true }; + } catch (err: any) { + console.error(`[${platform}] sendPhoto failed:`, err); + return { ok: false, error: err.message || "Request failed" }; + } +} diff --git a/server/medschedule/bot.routes.ts b/server/medschedule/bot.routes.ts new file mode 100644 index 0000000..9abcecc --- /dev/null +++ b/server/medschedule/bot.routes.ts @@ -0,0 +1,488 @@ +import type { Express, Request, Response } from "express"; +import crypto from "crypto"; +import { medScheduleStorage } from "./storage"; +import { sendBotMessage, sendBotPhoto } from "./bot-sender"; +import { storage } from "../storage"; +import { hashPassword } from "../utils/password"; +import { parseMedicationFromText, parsePurchaseFromText, parseMedicalRecordFromText, analyzeMedicalImageWithN8n } from "./ai.service"; +import { db } from "../db"; +import { users } from "@shared/schema"; +import { eq, and } from "drizzle-orm"; +import type { BotPlatform } from "./bot-sender"; + +interface NormalizedMessage { + platform: BotPlatform; + chatId: string; + from: { + id: string; + username?: string; + firstName?: string; + lastName?: string; + }; + text?: string; + photo?: { fileId: string }[]; +} + +const TELEGRAM_API_BASE = "https://api.telegram.org/bot"; +const MAX_API_BASE = process.env.MAX_BOT_API_BASE || "https://api.max.ru/bot"; + +function normalizeTelegramUpdate(body: any): NormalizedMessage | null { + const msg = body?.message; + if (!msg) return null; + return { + platform: "telegram", + chatId: String(msg.chat?.id), + from: { + id: String(msg.from?.id), + username: msg.from?.username, + firstName: msg.from?.first_name, + lastName: msg.from?.last_name, + }, + text: msg.text, + photo: msg.photo?.map((p: any) => ({ fileId: p.file_id })), + }; +} + +function normalizeMaxUpdate(body: any): NormalizedMessage | null { + // MAX Bot API — placeholder, структура уточняется + const msg = body?.message; + if (!msg) return null; + return { + platform: "max", + chatId: String(msg.chat?.id), + from: { + id: String(msg.from?.id), + username: msg.from?.username, + firstName: msg.from?.first_name, + lastName: msg.from?.last_name, + }, + text: msg.text, + photo: msg.photo?.map((p: any) => ({ fileId: p.file_id })), + }; +} + +function verifyTelegramSecret(token: string, body: any): boolean { + const secret = process.env.TELEGRAM_BOT_WEBHOOK_SECRET; + if (!secret) return true; // если не настроен — пропускаем + const check = crypto.createHmac("sha256", secret).update(JSON.stringify(body)).digest("hex"); + return token === check; +} + +function verifyMaxSecret(token: string, _body: any): boolean { + const secret = process.env.MAX_BOT_WEBHOOK_SECRET; + if (!secret) return true; + return token === secret; +} + +async function findOrCreateUserFromBot(platform: BotPlatform, msg: NormalizedMessage): Promise { + const orgId = await medScheduleStorage.ensureMedScheduleOrganization(); + const chatId = msg.chatId; + + // Сначала ищем по chatId + const existingBinding = + platform === "telegram" + ? await medScheduleStorage.getBotBindingByTelegramChatId(orgId, chatId) + : await medScheduleStorage.getBotBindingByMaxChatId(orgId, chatId); + + if (existingBinding) { + const user = await storage.getUser(existingBinding.userId); + if (user) return user; + } + + // Ищем по email + const email = `${platform}_${chatId}@medschedule.local`; + let user = await storage.getUserByEmail(email, orgId); + + if (!user) { + const password = crypto.randomBytes(12).toString("hex"); + user = await storage.createUser({ + organizationId: orgId, + email, + passwordHash: await hashPassword(password), + firstName: msg.from.firstName || msg.from.username || (platform === "telegram" ? "Telegram" : "MAX"), + lastName: msg.from.lastName || "Пользователь", + appRole: "user", + isActive: true, + }); + } + + if (platform === "telegram") { + await medScheduleStorage.bindTelegram(user.id, orgId, chatId, msg.from.username); + } else { + await medScheduleStorage.bindMax(user.id, orgId, chatId, msg.from.username); + } + + return user; +} + +async function getUserFamily(userId: number, orgId: number) { + const groups = await medScheduleStorage.getFamilyGroupsByUser(userId, orgId); + return groups[0] ?? null; +} + +async function reply(platform: BotPlatform, chatId: string, text: string) { + return sendBotMessage(platform, chatId, text, { parseMode: "HTML" }); +} + +async function handleCommand(platform: BotPlatform, msg: NormalizedMessage, user: any) { + const orgId = await medScheduleStorage.ensureMedScheduleOrganization(); + const chatId = msg.chatId; + const text = msg.text || ""; + const args = text.split(/\s+/); + const command = args[0]?.toLowerCase(); + const rest = args.slice(1).join(" ").trim(); + + switch (command) { + case "/start": + await reply(platform, chatId, `👋 Привет! Я бот MedSchedule.\n\nКоманды:\n/join <код> — вступить в семью\n/add <текст> — добавить лекарство\n/taken <название> — отметить приём\n/cabinet — аптечка\n/buy <описание> — куплено лекарство\n/record <описание> — запись обследования\n/status — сегодняшняя сводка\n/checkin — ответить на ежедневный вопрос`); + return; + + case "/join": { + const code = rest.toUpperCase(); + if (!code) { + await reply(platform, chatId, "Введите код приглашения: /join ABC123"); + return; + } + const group = await medScheduleStorage.getFamilyGroupByInviteCode(code, orgId); + if (!group) { + await reply(platform, chatId, "Семья с таким кодом не найдена."); + return; + } + const existing = await medScheduleStorage.getFamilyMembers(group.id, orgId); + const already = existing.find((m) => m.userId === user.id); + if (already) { + await reply(platform, chatId, `Вы уже в семье "${group.name}".`); + return; + } + await medScheduleStorage.createFamilyMember({ + familyId: group.id, + userId: user.id, + profileName: `${user.firstName ?? ""} ${user.lastName ?? ""}`.trim() || (platform === "telegram" ? "Telegram" : "MAX"), + role: "member", + createdBy: user.id, + }); + await reply(platform, chatId, `✅ Вы вступили в семью "${group.name}".`); + return; + } + + case "/add": { + const family = await getUserFamily(user.id, orgId); + if (!family) { + await reply(platform, chatId, "Сначала создайте или вступите в семью (/join <код>)."); + return; + } + const members = await medScheduleStorage.getFamilyMembers(family.id, orgId); + const targetMember = members.find((m) => m.userId === user.id) || members[0]; + if (!targetMember) { + await reply(platform, chatId, "В семье нет участников."); + return; + } + + if (!rest) { + await reply(platform, chatId, "Опишите лекарство: /add амоксициллин 500мг 2 раза в день 7 дней"); + return; + } + + const parsed = await parseMedicationFromText(orgId, rest); + if (!parsed) { + await reply(platform, chatId, "Не удалось распознать. Попробуйте формат: /add амоксициллин 500мг 2 раза в день 7 дней"); + return; + } + + const medication = await medScheduleStorage.createMedication({ + familyId: family.id, + memberId: targetMember.id, + name: parsed.name, + dosage: parsed.dosage || null, + form: parsed.form || null, + schedule: parsed.schedule, + instructions: parsed.instructions || null, + createdBy: user.id, + }); + + // Генерируем логи на 14 дней + await medScheduleStorage.generateIntakeLogs( + medication.id, + new Date(), + new Date(Date.now() + 14 * 24 * 60 * 60 * 1000) + ); + + await reply(platform, chatId, `✅ Добавлено лекарство ${medication.name}. Расписание создано.`); + return; + } + + case "/taken": { + const family = await getUserFamily(user.id, orgId); + if (!family) { + await reply(platform, chatId, "Сначала вступите в семью."); + return; + } + const member = (await medScheduleStorage.getFamilyMembers(family.id, orgId)).find((m) => m.userId === user.id); + if (!member) { + await reply(platform, chatId, "Вы не состоите в семье как участник."); + return; + } + + const now = new Date(); + const windowStart = new Date(now.getTime() - 60 * 60 * 1000); + const windowEnd = new Date(now.getTime() + 60 * 60 * 1000); + const intakes = await medScheduleStorage.getIntakesForDateRange(family.id, orgId, windowStart, windowEnd); + const target = intakes.find((i) => + (rest && i.medication.name.toLowerCase().includes(rest.toLowerCase())) || + i.intake.memberId === member.id + ); + + if (!target) { + await reply(platform, chatId, "Ближайший приём не найден."); + return; + } + + await medScheduleStorage.markIntakeTaken(target.intake.id, family.id, orgId, "отмечено через бот"); + await reply(platform, chatId, `✅ Отмечен приём ${target.medication.name}.`); + return; + } + + case "/cabinet": { + const family = await getUserFamily(user.id, orgId); + if (!family) { + await reply(platform, chatId, "Сначала вступите в семью."); + return; + } + const items = await medScheduleStorage.getCabinetItems(family.id, orgId); + if (items.length === 0) { + await reply(platform, chatId, "Аптечка пуста. Добавьте покупку: /buy парацетамол 20 шт"); + return; + } + const list = items + .map((i) => `- ${i.name}${i.quantity ? ` — ${i.quantity} ${i.unit}` : ""}${i.expiryDate ? ` (годен до ${i.expiryDate})` : ""}`) + .join("\n"); + await reply(platform, chatId, `💊 Аптечка:\n${list}`); + return; + } + + case "/buy": { + const family = await getUserFamily(user.id, orgId); + if (!family) { + await reply(platform, chatId, "Сначала вступите в семью."); + return; + } + if (!rest && !msg.photo) { + await reply(platform, chatId, "Опишите покупку: /buy парацетамол 20 шт"); + return; + } + + let name = rest; + let quantity = 1; + let unit = "шт"; + let photoUrl: string | undefined; + + if (msg.photo && msg.photo.length > 0) { + photoUrl = await resolvePhotoUrl(platform, msg.photo[msg.photo.length - 1].fileId); + } + + if (rest) { + const parsed = await parsePurchaseFromText(orgId, rest); + if (parsed) { + name = parsed.name; + quantity = parsed.quantity ?? 1; + unit = parsed.unit ?? "шт"; + } + } + + const item = await medScheduleStorage.upsertCabinetItem( + family.id, + orgId, + { + name, + quantity, + unit, + photoUrl, + }, + user.id + ); + + await medScheduleStorage.addCabinetStock(item!.id, family.id, orgId, quantity, "purchase", rest || undefined, user.id); + await reply(platform, chatId, `✅ Добавлено в аптечку: ${name} (${quantity} ${unit}).`); + return; + } + + case "/record": { + const family = await getUserFamily(user.id, orgId); + if (!family) { + await reply(platform, chatId, "Сначала вступите в семью."); + return; + } + const members = await medScheduleStorage.getFamilyMembers(family.id, orgId); + const targetMember = members.find((m) => m.userId === user.id) || members[0]; + if (!targetMember) { + await reply(platform, chatId, "В семье нет участников."); + return; + } + + if (!rest && !msg.photo) { + await reply(platform, chatId, "Опишите обследование или прикрепите фото: /record анализ крови, лейкоциты повышены"); + return; + } + + let photoUrl: string | undefined; + if (msg.photo && msg.photo.length > 0) { + photoUrl = await resolvePhotoUrl(platform, msg.photo[msg.photo.length - 1].fileId); + } + + let title = rest || "Обследование"; + let recordType = "doctor_note"; + let aiSummary: string | null = null; + let aiFindings: any = null; + let recordDate = new Date().toISOString().split("T")[0]; + let rawText = rest || null; + + if (photoUrl) { + const ocr = await analyzeMedicalImageWithN8n(photoUrl); + if (ocr) { + title = ocr.title || title; + recordType = ocr.recordType || recordType; + aiSummary = ocr.summary || null; + aiFindings = ocr.findings || null; + recordDate = ocr.recordDate || recordDate; + rawText = ocr.rawText || rawText; + } + } else if (rest) { + const parsed = await parseMedicalRecordFromText(orgId, rest); + if (parsed) { + title = parsed.title; + recordType = parsed.recordType; + aiSummary = parsed.summary; + aiFindings = parsed.findings; + recordDate = parsed.recordDate || recordDate; + rawText = parsed.rawText || rawText; + } + } + + await medScheduleStorage.createMedicalRecord({ + familyId: family.id, + memberId: targetMember.id, + title, + recordType, + recordDate, + rawText, + aiSummary, + aiFindings, + fileUrls: photoUrl ? [photoUrl] : null, + addedBy: user.id, + }); + + await reply(platform, chatId, `✅ Запись "${title}" сохранена.${aiSummary ? `\nКратко: ${aiSummary.slice(0, 200)}` : ""}`); + return; + } + + case "/status": { + const family = await getUserFamily(user.id, orgId); + if (!family) { + await reply(platform, chatId, "Сначала вступите в семью."); + return; + } + const now = new Date(); + const start = new Date(now); start.setHours(0, 0, 0, 0); + const end = new Date(now); end.setHours(23, 59, 59, 999); + const intakes = await medScheduleStorage.getIntakesForDateRange(family.id, orgId, start, end); + const pending = intakes.filter((i) => i.intake.status === "pending"); + const taken = intakes.filter((i) => i.intake.status === "taken"); + + if (intakes.length === 0) { + await reply(platform, chatId, "На сегодня приёмов лекарств не запланировано."); + return; + } + + const lines = intakes.map((i) => { + const time = i.intake.scheduledAt.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit", timeZone: "Europe/Moscow" }); + const status = i.intake.status === "taken" ? "✅" : i.intake.status === "skipped" ? "❌" : "⏳"; + return `${status} ${time} — ${i.medication.name} (${i.member.profileName})`; + }); + await reply(platform, chatId, `📋 Сводка на сегодня:\n${lines.join("\n")}\n\nПринято: ${taken.length}/${intakes.length}`); + return; + } + + case "/checkin": { + const family = await getUserFamily(user.id, orgId); + if (!family) { + await reply(platform, chatId, "Сначала вступите в семью."); + return; + } + const pending = await medScheduleStorage.getPendingCheckins(family.id, orgId); + const today = pending.find((c) => c.checkInDate === new Date().toISOString().split("T")[0]); + if (!today) { + await reply(platform, chatId, "Сегодня нет активного вопроса. Он придёт утром."); + return; + } + if (!rest) { + await reply(platform, chatId, `🩺 ${today.question}\n\nОтветьте: /checkin <ваш ответ>`); + return; + } + // TODO: summarize via AI + await medScheduleStorage.submitCheckinAnswer(today.id, family.id, orgId, rest); + await reply(platform, chatId, "✅ Ответ сохранён. Спасибо!"); + return; + } + + default: + await reply(platform, chatId, "Неизвестная команда. Список команд: /start"); + } +} + +async function resolvePhotoUrl(platform: BotPlatform, fileId: string): Promise { + const token = platform === "telegram" ? process.env.TELEGRAM_BOT_TOKEN : process.env.MAX_BOT_TOKEN; + if (!token) return undefined; + const base = platform === "telegram" ? `${TELEGRAM_API_BASE}${token}` : `${MAX_API_BASE}${token}`; + try { + const res = await fetch(`${base}/getFile`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ file_id: fileId }), + }); + const data = await res.json() as any; + const filePath = data?.result?.file_path; + if (!filePath) return undefined; + return platform === "telegram" + ? `https://api.telegram.org/file/bot${token}/${filePath}` + : `${MAX_API_BASE}${token}/${filePath}`; + } catch (err) { + console.error("[MedSchedule Bot] resolvePhotoUrl error:", err); + return undefined; + } +} + +export function registerMedScheduleBotRoutes(app: Express) { + app.post("/api/med/bot/webhook/telegram", async (req: Request, res: Response) => { + const secret = req.headers["x-telegram-bot-api-secret-token"] as string; + if (!verifyTelegramSecret(secret, req.body)) { + return res.status(401).json({ ok: false }); + } + const msg = normalizeTelegramUpdate(req.body); + if (!msg) return res.json({ ok: true }); + + try { + const user = await findOrCreateUserFromBot("telegram", msg); + await handleCommand("telegram", msg, user); + } catch (err) { + console.error("[MedSchedule Bot] Telegram handler error:", err); + } + res.json({ ok: true }); + }); + + app.post("/api/med/bot/webhook/max", async (req: Request, res: Response) => { + const secret = req.headers["x-max-bot-api-secret-token"] as string; + if (!verifyMaxSecret(secret, req.body)) { + return res.status(401).json({ ok: false }); + } + const msg = normalizeMaxUpdate(req.body); + if (!msg) return res.json({ ok: true }); + + try { + const user = await findOrCreateUserFromBot("max", msg); + await handleCommand("max", msg, user); + } catch (err) { + console.error("[MedSchedule Bot] MAX handler error:", err); + } + res.json({ ok: true }); + }); +} diff --git a/server/medschedule/routes.ts b/server/medschedule/routes.ts new file mode 100644 index 0000000..88722e3 --- /dev/null +++ b/server/medschedule/routes.ts @@ -0,0 +1,473 @@ +import type { Express, Request, Response, NextFunction } from "express"; +import type { Response as ExpressResponse } from "express"; +import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware"; +import { tenantIsolation } from "../middleware/tenant.middleware"; +import { medScheduleStorage } from "./storage"; + +interface MedRequest extends AuthenticatedRequest { + medOrganizationId?: number; +} + +let cachedMedOrgId: number | null = null; + +async function resolveMedOrganizationId(): Promise { + if (cachedMedOrgId) return cachedMedOrgId; + cachedMedOrgId = await medScheduleStorage.ensureMedScheduleOrganization(); + return cachedMedOrgId; +} + +function medOrgResolver() { + return async (_req: Request, _res: Response, next: NextFunction) => { + try { + await resolveMedOrganizationId(); + next(); + } catch (err) { + next(err as Error); + } + }; +} + +function requireMedOrganization() { + return async (req: MedRequest, res: Response, next: NextFunction) => { + const medOrgId = await resolveMedOrganizationId(); + req.medOrganizationId = medOrgId; + + // Если запрос от аутентифицированного пользователя, убедимся, что он принадлежит к org MedSchedule + if (req.user && req.user.organizationId !== medOrgId) { + return res.status(403).json({ + success: false, + error: "Этот раздел доступен только пользователям MedSchedule", + }); + } + + next(); + }; +} + +function parseDate(value: unknown): Date | null { + if (typeof value !== "string" || !value) return null; + const d = new Date(value); + if (isNaN(d.getTime())) return null; + return d; +} + +export function registerMedScheduleRoutes(app: Express) { + const router = require("express").Router(); + router.use(authenticateToken); + router.use(tenantIsolation); + router.use(medOrgResolver()); + router.use(requireMedOrganization()); + + // ===================== + // Family groups + // ===================== + + router.get("/family", async (req: MedRequest, res: ExpressResponse) => { + const orgId = req.medOrganizationId!; + const groups = await medScheduleStorage.getFamilyGroupsByUser(req.user!.id, orgId); + res.json({ success: true, groups }); + }); + + router.post("/family", async (req: MedRequest, res: ExpressResponse) => { + const orgId = req.medOrganizationId!; + const { name } = req.body; + if (!name || typeof name !== "string") { + return res.status(400).json({ success: false, error: "Название семьи обязательно" }); + } + const group = await medScheduleStorage.createFamilyGroup({ + name, + organizationId: orgId, + createdBy: req.user!.id, + }); + res.status(201).json({ success: true, group }); + }); + + router.get("/family/:id", async (req: MedRequest, res: ExpressResponse) => { + const orgId = req.medOrganizationId!; + const group = await medScheduleStorage.getFamilyGroupById(Number(req.params.id), orgId); + if (!group) return res.status(404).json({ success: false, error: "Семья не найдена" }); + const members = await medScheduleStorage.getFamilyMembersWithBotBindings(group.id, orgId); + res.json({ success: true, group, members }); + }); + + router.patch("/family/:id", async (req: MedRequest, res: ExpressResponse) => { + const orgId = req.medOrganizationId!; + const group = await medScheduleStorage.updateFamilyGroup(Number(req.params.id), orgId, req.body); + if (!group) return res.status(404).json({ success: false, error: "Семья не найдена" }); + res.json({ success: true, group }); + }); + + router.delete("/family/:id", async (req: MedRequest, res: ExpressResponse) => { + const orgId = req.medOrganizationId!; + await medScheduleStorage.deleteFamilyGroup(Number(req.params.id), orgId); + res.json({ success: true }); + }); + + router.post("/family/join", async (req: MedRequest, res: ExpressResponse) => { + const orgId = req.medOrganizationId!; + const { inviteCode } = req.body; + if (!inviteCode || typeof inviteCode !== "string") { + return res.status(400).json({ success: false, error: "Код приглашения обязателен" }); + } + const group = await medScheduleStorage.getFamilyGroupByInviteCode(inviteCode.toUpperCase(), orgId); + if (!group) return res.status(404).json({ success: false, error: "Семья не найдена" }); + + // Создаём member для текущего пользователя + const member = await medScheduleStorage.createFamilyMember({ + familyId: group.id, + userId: req.user!.id, + profileName: `${req.user!.firstName ?? ""} ${req.user!.lastName ?? ""}`.trim() || "Я", + role: "member", + createdBy: req.user!.id, + }); + res.json({ success: true, group, member }); + }); + + // ===================== + // Family members + // ===================== + + router.get("/family/:familyId/members", async (req: MedRequest, res: ExpressResponse) => { + const orgId = req.medOrganizationId!; + const members = await medScheduleStorage.getFamilyMembersWithBotBindings(Number(req.params.familyId), orgId); + res.json({ success: true, members }); + }); + + router.post("/family/:familyId/members", async (req: MedRequest, res: ExpressResponse) => { + const orgId = req.medOrganizationId!; + const { profileName, profileType, dateOfBirth, gender, notes, role } = req.body; + if (!profileName || typeof profileName !== "string") { + return res.status(400).json({ success: false, error: "Имя обязательно" }); + } + const member = await medScheduleStorage.createFamilyMember({ + familyId: Number(req.params.familyId), + userId: null, + profileName, + profileType: profileType || "person", + dateOfBirth: dateOfBirth || null, + gender: gender || null, + notes: notes || null, + role: role || "member", + createdBy: req.user!.id, + }); + res.status(201).json({ success: true, member }); + }); + + router.patch("/family/:familyId/members/:id", async (req: MedRequest, res: ExpressResponse) => { + const orgId = req.medOrganizationId!; + const member = await medScheduleStorage.updateFamilyMember( + Number(req.params.id), + Number(req.params.familyId), + orgId, + req.body + ); + if (!member) return res.status(404).json({ success: false, error: "Участник не найден" }); + res.json({ success: true, member }); + }); + + router.delete("/family/:familyId/members/:id", async (req: MedRequest, res: ExpressResponse) => { + const orgId = req.medOrganizationId!; + await medScheduleStorage.deleteFamilyMember(Number(req.params.id), Number(req.params.familyId), orgId); + res.json({ success: true }); + }); + + // ===================== + // Medications + // ===================== + + router.get("/medications", async (req: MedRequest, res: ExpressResponse) => { + const orgId = req.medOrganizationId!; + const { familyId, memberId } = req.query; + let meds; + if (memberId) { + meds = await medScheduleStorage.getMedicationsByMember(Number(memberId), Number(familyId || 0), orgId); + } else if (familyId) { + meds = await medScheduleStorage.getMedicationsByFamily(Number(familyId), orgId); + } else { + return res.status(400).json({ success: false, error: "Требуется familyId или memberId" }); + } + res.json({ success: true, medications: meds }); + }); + + router.post("/medications", async (req: MedRequest, res: ExpressResponse) => { + const orgId = req.medOrganizationId!; + const { familyId, memberId, name, dosage, form, schedule, instructions, color } = req.body; + if (!familyId || !memberId || !name || !schedule) { + return res.status(400).json({ success: false, error: "familyId, memberId, name и schedule обязательны" }); + } + const medication = await medScheduleStorage.createMedication({ + familyId, + memberId, + name, + dosage: dosage || null, + form: form || null, + schedule, + instructions: instructions || null, + color: color || "#4CAF50", + createdBy: req.user!.id, + }); + res.status(201).json({ success: true, medication }); + }); + + router.patch("/medications/:id", async (req: MedRequest, res: ExpressResponse) => { + const orgId = req.medOrganizationId!; + const { familyId } = req.body; + if (!familyId) return res.status(400).json({ success: false, error: "familyId обязателен" }); + const medication = await medScheduleStorage.updateMedication(Number(req.params.id), familyId, orgId, req.body); + if (!medication) return res.status(404).json({ success: false, error: "Лекарство не найдено" }); + res.json({ success: true, medication }); + }); + + router.delete("/medications/:id", async (req: MedRequest, res: ExpressResponse) => { + const orgId = req.medOrganizationId!; + const { familyId } = req.body; + if (!familyId) return res.status(400).json({ success: false, error: "familyId обязателен" }); + await medScheduleStorage.deleteMedication(Number(req.params.id), familyId, orgId); + res.json({ success: true }); + }); + + router.post("/medications/:id/generate-logs", async (req: MedRequest, res: ExpressResponse) => { + const orgId = req.medOrganizationId!; + const { familyId, from, to } = req.body; + if (!familyId || !from || !to) { + return res.status(400).json({ success: false, error: "familyId, from и to обязательны" }); + } + const logs = await medScheduleStorage.generateIntakeLogs( + Number(req.params.id), + new Date(from), + new Date(to) + ); + res.json({ success: true, logs }); + }); + + // ===================== + // Intake logs + // ===================== + + router.get("/intakes", async (req: MedRequest, res: ExpressResponse) => { + const orgId = req.medOrganizationId!; + const { familyId, from, to } = req.query; + if (!familyId || !from || !to) { + return res.status(400).json({ success: false, error: "familyId, from и to обязательны" }); + } + const intakes = await medScheduleStorage.getIntakesForDateRange( + Number(familyId), + orgId, + new Date(from as string), + new Date(to as string) + ); + res.json({ success: true, intakes }); + }); + + router.post("/intakes/:id/taken", async (req: MedRequest, res: ExpressResponse) => { + const orgId = req.medOrganizationId!; + const { familyId, note } = req.body; + if (!familyId) return res.status(400).json({ success: false, error: "familyId обязателен" }); + const intake = await medScheduleStorage.markIntakeTaken(Number(req.params.id), familyId, orgId, note); + if (!intake) return res.status(404).json({ success: false, error: "Приём не найден" }); + res.json({ success: true, intake }); + }); + + router.post("/intakes/:id/skipped", async (req: MedRequest, res: ExpressResponse) => { + const orgId = req.medOrganizationId!; + const { familyId, note } = req.body; + if (!familyId) return res.status(400).json({ success: false, error: "familyId обязателен" }); + const intake = await medScheduleStorage.markIntakeSkipped(Number(req.params.id), familyId, orgId, note); + if (!intake) return res.status(404).json({ success: false, error: "Приём не найден" }); + res.json({ success: true, intake }); + }); + + router.post("/intakes/:id/snooze", async (req: MedRequest, res: ExpressResponse) => { + const orgId = req.medOrganizationId!; + const { familyId, minutes } = req.body; + if (!familyId || !minutes) return res.status(400).json({ success: false, error: "familyId и minutes обязательны" }); + const intake = await medScheduleStorage.snoozeIntake(Number(req.params.id), familyId, orgId, Number(minutes)); + if (!intake) return res.status(404).json({ success: false, error: "Приём не найден" }); + res.json({ success: true, intake }); + }); + + // ===================== + // Cabinet + // ===================== + + router.get("/cabinet", async (req: MedRequest, res: ExpressResponse) => { + const orgId = req.medOrganizationId!; + const { familyId } = req.query; + if (!familyId) return res.status(400).json({ success: false, error: "familyId обязателен" }); + const items = await medScheduleStorage.getCabinetItems(Number(familyId), orgId); + res.json({ success: true, items }); + }); + + router.post("/cabinet", async (req: MedRequest, res: ExpressResponse) => { + const orgId = req.medOrganizationId!; + const { id, familyId, ...data } = req.body; + if (!familyId) return res.status(400).json({ success: false, error: "familyId обязателен" }); + const item = await medScheduleStorage.upsertCabinetItem( + familyId, + orgId, + { id, ...data }, + req.user!.id + ); + if (!item) return res.status(404).json({ success: false, error: "Не удалось сохранить" }); + res.json({ success: true, item }); + }); + + router.post("/cabinet/:id/stock", async (req: MedRequest, res: ExpressResponse) => { + const orgId = req.medOrganizationId!; + const { familyId, change, reason, note } = req.body; + if (!familyId || change === undefined) { + return res.status(400).json({ success: false, error: "familyId и change обязательны" }); + } + const item = await medScheduleStorage.addCabinetStock( + Number(req.params.id), + familyId, + orgId, + Number(change), + reason || "manual", + note, + req.user!.id + ); + if (!item) return res.status(404).json({ success: false, error: "Позиция не найдена" }); + res.json({ success: true, item }); + }); + + router.delete("/cabinet/:id", async (req: MedRequest, res: ExpressResponse) => { + const orgId = req.medOrganizationId!; + const { familyId } = req.body; + if (!familyId) return res.status(400).json({ success: false, error: "familyId обязателен" }); + await medScheduleStorage.deleteCabinetItem(Number(req.params.id), familyId, orgId); + res.json({ success: true }); + }); + + // ===================== + // Medical records + // ===================== + + router.get("/records", async (req: MedRequest, res: ExpressResponse) => { + const orgId = req.medOrganizationId!; + const { familyId, memberId } = req.query; + if (!familyId) return res.status(400).json({ success: false, error: "familyId обязателен" }); + const records = await medScheduleStorage.getMedicalRecords( + Number(familyId), + orgId, + memberId ? Number(memberId) : undefined + ); + res.json({ success: true, records }); + }); + + router.post("/records", async (req: MedRequest, res: ExpressResponse) => { + const orgId = req.medOrganizationId!; + const { familyId, memberId, title, recordType, recordDate, doctorName, clinicName, rawText, aiSummary, aiFindings, fileUrls } = req.body; + if (!familyId || !memberId || !title || !recordType) { + return res.status(400).json({ success: false, error: "familyId, memberId, title и recordType обязательны" }); + } + const record = await medScheduleStorage.createMedicalRecord({ + familyId, + memberId, + title, + recordType, + recordDate: recordDate || null, + doctorName: doctorName || null, + clinicName: clinicName || null, + rawText: rawText || null, + aiSummary: aiSummary || null, + aiFindings: aiFindings || null, + fileUrls: fileUrls || null, + addedBy: req.user!.id, + }); + res.status(201).json({ success: true, record }); + }); + + router.patch("/records/:id", async (req: MedRequest, res: ExpressResponse) => { + const orgId = req.medOrganizationId!; + const { familyId } = req.body; + if (!familyId) return res.status(400).json({ success: false, error: "familyId обязателен" }); + const record = await medScheduleStorage.updateMedicalRecord(Number(req.params.id), familyId, orgId, req.body); + if (!record) return res.status(404).json({ success: false, error: "Запись не найдена" }); + res.json({ success: true, record }); + }); + + router.delete("/records/:id", async (req: MedRequest, res: ExpressResponse) => { + const orgId = req.medOrganizationId!; + const { familyId } = req.body; + if (!familyId) return res.status(400).json({ success: false, error: "familyId обязателен" }); + await medScheduleStorage.deleteMedicalRecord(Number(req.params.id), familyId, orgId); + res.json({ success: true }); + }); + + // ===================== + // Daily check-ins + // ===================== + + router.get("/checkins", async (req: MedRequest, res: ExpressResponse) => { + const orgId = req.medOrganizationId!; + const { familyId } = req.query; + if (!familyId) return res.status(400).json({ success: false, error: "familyId обязателен" }); + const checkins = await medScheduleStorage.getPendingCheckins(Number(familyId), orgId); + res.json({ success: true, checkins }); + }); + + router.post("/checkins", async (req: MedRequest, res: ExpressResponse) => { + const orgId = req.medOrganizationId!; + const { familyId, memberId, question, checkInDate } = req.body; + if (!familyId || !question || !checkInDate) { + return res.status(400).json({ success: false, error: "familyId, question и checkInDate обязательны" }); + } + const checkin = await medScheduleStorage.createDailyCheckin({ + familyId, + memberId: memberId || null, + checkInDate, + question, + }); + res.status(201).json({ success: true, checkin }); + }); + + router.post("/checkins/:id/answer", async (req: MedRequest, res: ExpressResponse) => { + const orgId = req.medOrganizationId!; + const { familyId, answerText, aiSummary, aiAlert } = req.body; + if (!familyId || !answerText) { + return res.status(400).json({ success: false, error: "familyId и answerText обязательны" }); + } + const checkin = await medScheduleStorage.submitCheckinAnswer( + Number(req.params.id), + familyId, + orgId, + answerText, + aiSummary, + aiAlert + ); + if (!checkin) return res.status(404).json({ success: false, error: "Опрос не найден" }); + res.json({ success: true, checkin }); + }); + + // ===================== + // Calendar + // ===================== + + router.get("/calendar", async (req: MedRequest, res: ExpressResponse) => { + const orgId = req.medOrganizationId!; + const { familyId, from, to } = req.query; + if (!familyId || !from || !to) { + return res.status(400).json({ success: false, error: "familyId, from и to обязательны" }); + } + const intakes = await medScheduleStorage.getIntakesForDateRange( + Number(familyId), + orgId, + new Date(from as string), + new Date(to as string) + ); + const events = intakes.map((i) => ({ + id: i.intake.id, + title: i.medication.name, + memberName: i.member.profileName, + scheduledAt: i.intake.scheduledAt.toISOString(), + status: i.intake.status, + medicationId: i.medication.id, + memberId: i.member.id, + color: i.medication.color, + })); + res.json({ success: true, events }); + }); + + app.use("/api/med", router); +} diff --git a/server/medschedule/storage.ts b/server/medschedule/storage.ts new file mode 100644 index 0000000..2eda9f2 --- /dev/null +++ b/server/medschedule/storage.ts @@ -0,0 +1,696 @@ +import { db } from "../db"; +import { + medFamilyGroups, + medFamilyMembers, + medMedications, + medIntakeLogs, + medCabinetItems, + medCabinetStock, + medMedicalRecords, + medDailyCheckins, + medBotBindings, + organizations, + users, + type InsertMedFamilyGroup, + type InsertMedFamilyMember, + type InsertMedMedication, + type InsertMedIntakeLog, + type InsertMedCabinetItem, + type InsertMedCabinetStock, + type InsertMedMedicalRecord, + type InsertMedDailyCheckin, + type InsertMedBotBinding, +} from "@shared/schema"; +import { eq, and, gte, lte, asc, desc, inArray, sql, isNull } from "drizzle-orm"; +import crypto from "crypto"; + +export class MedScheduleStorage { + // ===================== + // Organizations + // ===================== + + async ensureMedScheduleOrganization(): Promise { + const slug = "medschedule"; + const [existing] = await db + .select({ id: organizations.id }) + .from(organizations) + .where(eq(organizations.slug, slug)); + if (existing) return existing.id; + + const [org] = await db + .insert(organizations) + .values({ + name: "MedSchedule", + slug, + displayName: "MedSchedule", + }) + .returning(); + return org.id; + } + + // ===================== + // Family groups + // ===================== + + private generateInviteCode(): string { + return crypto.randomBytes(4).toString("hex").toUpperCase(); + } + + async createFamilyGroup(data: Omit) { + const [group] = await db + .insert(medFamilyGroups) + .values({ + ...data, + inviteCode: this.generateInviteCode(), + }) + .returning(); + return group; + } + + async getFamilyGroupById(id: number, organizationId: number) { + const [group] = await db + .select() + .from(medFamilyGroups) + .where(and(eq(medFamilyGroups.id, id), eq(medFamilyGroups.organizationId, organizationId))); + return group ?? null; + } + + async getFamilyGroupByInviteCode(inviteCode: string, organizationId: number) { + const [group] = await db + .select() + .from(medFamilyGroups) + .where(and(eq(medFamilyGroups.inviteCode, inviteCode), eq(medFamilyGroups.organizationId, organizationId))); + return group ?? null; + } + + async getAllFamilyGroups(organizationId: number) { + return db + .select() + .from(medFamilyGroups) + .where(eq(medFamilyGroups.organizationId, organizationId)) + .orderBy(desc(medFamilyGroups.createdAt)); + } + + async getFamilyGroupsByUser(userId: number, organizationId: number) { + const owned = await db + .select() + .from(medFamilyGroups) + .where(and(eq(medFamilyGroups.createdBy, userId), eq(medFamilyGroups.organizationId, organizationId))); + + const memberRows = await db + .select({ familyId: medFamilyMembers.familyId }) + .from(medFamilyMembers) + .where(eq(medFamilyMembers.userId, userId)); + + const memberFamilyIds = memberRows.map((m) => m.familyId).filter(Boolean); + if (memberFamilyIds.length === 0) return owned; + + const joined = await db + .select() + .from(medFamilyGroups) + .where(and(inArray(medFamilyGroups.id, memberFamilyIds), eq(medFamilyGroups.organizationId, organizationId))); + + const map = new Map(); + for (const g of owned) map.set(g.id, g); + for (const g of joined) map.set(g.id, g); + return Array.from(map.values()); + } + + async updateFamilyGroup(id: number, organizationId: number, updates: Partial) { + const [group] = await db + .update(medFamilyGroups) + .set({ ...updates, updatedAt: new Date() }) + .where(and(eq(medFamilyGroups.id, id), eq(medFamilyGroups.organizationId, organizationId))) + .returning(); + return group ?? null; + } + + async deleteFamilyGroup(id: number, organizationId: number) { + await db + .delete(medFamilyGroups) + .where(and(eq(medFamilyGroups.id, id), eq(medFamilyGroups.organizationId, organizationId))); + } + + // ===================== + // Family members + // ===================== + + async createFamilyMember(data: InsertMedFamilyMember) { + const [member] = await db.insert(medFamilyMembers).values(data).returning(); + return member; + } + + async getFamilyMembers(familyId: number, organizationId: number) { + const group = await this.getFamilyGroupById(familyId, organizationId); + if (!group) return []; + return db + .select() + .from(medFamilyMembers) + .where(eq(medFamilyMembers.familyId, familyId)) + .orderBy(asc(medFamilyMembers.profileName)); + } + + async getFamilyMemberById(id: number, familyId: number, organizationId: number) { + const group = await this.getFamilyGroupById(familyId, organizationId); + if (!group) return null; + const [member] = await db + .select() + .from(medFamilyMembers) + .where(and(eq(medFamilyMembers.id, id), eq(medFamilyMembers.familyId, familyId))); + return member ?? null; + } + + async updateFamilyMember(id: number, familyId: number, organizationId: number, updates: Partial) { + const group = await this.getFamilyGroupById(familyId, organizationId); + if (!group) return null; + const [member] = await db + .update(medFamilyMembers) + .set({ ...updates, updatedAt: new Date() }) + .where(and(eq(medFamilyMembers.id, id), eq(medFamilyMembers.familyId, familyId))) + .returning(); + return member ?? null; + } + + async deleteFamilyMember(id: number, familyId: number, organizationId: number) { + const group = await this.getFamilyGroupById(familyId, organizationId); + if (!group) return; + await db + .delete(medFamilyMembers) + .where(and(eq(medFamilyMembers.id, id), eq(medFamilyMembers.familyId, familyId))); + } + + // ===================== + // Medications + // ===================== + + async createMedication(data: InsertMedMedication) { + const [med] = await db.insert(medMedications).values(data).returning(); + return med; + } + + async getMedicationById(id: number, familyId: number, organizationId: number) { + const [med] = await db + .select() + .from(medMedications) + .where(and(eq(medMedications.id, id), eq(medMedications.familyId, familyId))) + .innerJoin(medFamilyGroups, eq(medFamilyGroups.id, medMedications.familyId)); + return med ?? null; + } + + async getMedicationsByFamily(familyId: number, organizationId: number) { + const group = await this.getFamilyGroupById(familyId, organizationId); + if (!group) return []; + return db + .select() + .from(medMedications) + .where(eq(medMedications.familyId, familyId)) + .orderBy(asc(medMedications.name)); + } + + async getMedicationsByMember(memberId: number, familyId: number, organizationId: number) { + const group = await this.getFamilyGroupById(familyId, organizationId); + if (!group) return []; + return db + .select() + .from(medMedications) + .where(and(eq(medMedications.memberId, memberId), eq(medMedications.familyId, familyId))) + .orderBy(asc(medMedications.name)); + } + + async updateMedication(id: number, familyId: number, organizationId: number, updates: Partial) { + const group = await this.getFamilyGroupById(familyId, organizationId); + if (!group) return null; + const [med] = await db + .update(medMedications) + .set({ ...updates, updatedAt: new Date() }) + .where(and(eq(medMedications.id, id), eq(medMedications.familyId, familyId))) + .returning(); + return med ?? null; + } + + async deleteMedication(id: number, familyId: number, organizationId: number) { + const group = await this.getFamilyGroupById(familyId, organizationId); + if (!group) return; + await db + .delete(medMedications) + .where(and(eq(medMedications.id, id), eq(medMedications.familyId, familyId))); + } + + // ===================== + // Intake logs generation & queries + // ===================== + + async generateIntakeLogs(medicationId: number, from: Date, to: Date) { + const [med] = await db.select().from(medMedications).where(eq(medMedications.id, medicationId)); + if (!med || !med.isActive) return []; + + const schedule = med.schedule as { + type: "daily" | "weekly" | "interval" | "as_needed"; + times: string[]; + days?: number[]; + intervalHours?: number; + startDate?: string; + endDate?: string; + }; + + const start = new Date(schedule.startDate ?? med.createdAt); + const end = schedule.endDate ? new Date(schedule.endDate) : to; + const effectiveFrom = from > start ? from : start; + const effectiveTo = to < end ? to : end; + + const slots: Date[] = []; + const current = new Date(effectiveFrom); + current.setHours(0, 0, 0, 0); + + while (current <= effectiveTo) { + const dayOfWeek = current.getDay(); // 0..6 + const matchesDay = + schedule.type === "daily" || + schedule.type === "as_needed" || + (schedule.type === "weekly" && schedule.days?.includes(dayOfWeek)); + + if (matchesDay) { + for (const time of schedule.times ?? []) { + const [hours, minutes] = time.split(":").map(Number); + const slot = new Date(current); + slot.setHours(hours, minutes, 0, 0); + if (slot >= effectiveFrom && slot <= effectiveTo) { + slots.push(slot); + } + } + } + + if (schedule.type === "interval" && schedule.intervalHours) { + // interval handling: one base time per day + intervalHours + const [hours, minutes] = (schedule.times[0] ?? "08:00").split(":").map(Number); + let slot = new Date(current); + slot.setHours(hours, minutes, 0, 0); + while (slot <= effectiveTo && slot.getDate() === current.getDate()) { + if (slot >= effectiveFrom) slots.push(new Date(slot)); + slot = new Date(slot.getTime() + schedule.intervalHours * 60 * 60 * 1000); + } + } + + current.setDate(current.getDate() + 1); + } + + if (slots.length === 0) return []; + + // Delete overlapping pending intakes in range to avoid duplicates + await db + .delete(medIntakeLogs) + .where( + and( + eq(medIntakeLogs.medicationId, medicationId), + eq(medIntakeLogs.status, "pending"), + gte(medIntakeLogs.scheduledAt, effectiveFrom), + lte(medIntakeLogs.scheduledAt, effectiveTo) + ) + ); + + const values: InsertMedIntakeLog[] = slots.map((slot) => ({ + medicationId, + memberId: med.memberId, + scheduledAt: slot, + status: "pending", + })); + + return db.insert(medIntakeLogs).values(values).returning(); + } + + async getPendingIntakes(organizationId: number, before: Date, after: Date = new Date(0)) { + return db + .select({ + intake: medIntakeLogs, + medication: medMedications, + member: medFamilyMembers, + family: medFamilyGroups, + }) + .from(medIntakeLogs) + .innerJoin(medMedications, eq(medMedications.id, medIntakeLogs.medicationId)) + .innerJoin(medFamilyMembers, eq(medFamilyMembers.id, medIntakeLogs.memberId)) + .innerJoin(medFamilyGroups, eq(medFamilyGroups.id, medMedications.familyId)) + .where( + and( + eq(medFamilyGroups.organizationId, organizationId), + eq(medIntakeLogs.status, "pending"), + gte(medIntakeLogs.scheduledAt, after), + lte(medIntakeLogs.scheduledAt, before) + ) + ) + .orderBy(asc(medIntakeLogs.scheduledAt)); + } + + async getMissedIntakes(organizationId: number, minutesAgo: number) { + const cutoff = new Date(Date.now() - minutesAgo * 60 * 1000); + return db + .select({ + intake: medIntakeLogs, + medication: medMedications, + member: medFamilyMembers, + family: medFamilyGroups, + }) + .from(medIntakeLogs) + .innerJoin(medMedications, eq(medMedications.id, medIntakeLogs.medicationId)) + .innerJoin(medFamilyMembers, eq(medFamilyMembers.id, medIntakeLogs.memberId)) + .innerJoin(medFamilyGroups, eq(medFamilyGroups.id, medMedications.familyId)) + .where( + and( + eq(medFamilyGroups.organizationId, organizationId), + eq(medIntakeLogs.status, "pending"), + lte(medIntakeLogs.scheduledAt, cutoff) + ) + ); + } + + async getIntakesForDateRange(familyId: number, organizationId: number, from: Date, to: Date) { + const group = await this.getFamilyGroupById(familyId, organizationId); + if (!group) return []; + return db + .select({ + intake: medIntakeLogs, + medication: medMedications, + member: medFamilyMembers, + }) + .from(medIntakeLogs) + .innerJoin(medMedications, eq(medMedications.id, medIntakeLogs.medicationId)) + .innerJoin(medFamilyMembers, eq(medFamilyMembers.id, medIntakeLogs.memberId)) + .where( + and( + eq(medMedications.familyId, familyId), + gte(medIntakeLogs.scheduledAt, from), + lte(medIntakeLogs.scheduledAt, to) + ) + ) + .orderBy(asc(medIntakeLogs.scheduledAt)); + } + + async markIntakeTaken(intakeId: number, familyId: number, organizationId: number, note?: string) { + const [intake] = await db + .select({ intake: medIntakeLogs, medication: medMedications }) + .from(medIntakeLogs) + .innerJoin(medMedications, eq(medMedications.id, medIntakeLogs.medicationId)) + .where(and(eq(medIntakeLogs.id, intakeId), eq(medMedications.familyId, familyId))) + .innerJoin(medFamilyGroups, eq(medFamilyGroups.id, medMedications.familyId)); + if (!intake || intake.medication.familyId !== familyId) return null; + + const [updated] = await db + .update(medIntakeLogs) + .set({ status: "taken", takenAt: new Date(), note: note ?? null }) + .where(eq(medIntakeLogs.id, intakeId)) + .returning(); + return updated ?? null; + } + + async markIntakeSkipped(intakeId: number, familyId: number, organizationId: number, note?: string) { + const [intake] = await db + .select({ intake: medIntakeLogs, medication: medMedications }) + .from(medIntakeLogs) + .innerJoin(medMedications, eq(medMedications.id, medIntakeLogs.medicationId)) + .where(and(eq(medIntakeLogs.id, intakeId), eq(medMedications.familyId, familyId))) + .innerJoin(medFamilyGroups, eq(medFamilyGroups.id, medMedications.familyId)); + if (!intake) return null; + + const [updated] = await db + .update(medIntakeLogs) + .set({ status: "skipped", note: note ?? null }) + .where(eq(medIntakeLogs.id, intakeId)) + .returning(); + return updated ?? null; + } + + async snoozeIntake(intakeId: number, familyId: number, organizationId: number, minutes: number) { + const [intake] = await db + .select({ intake: medIntakeLogs, medication: medMedications }) + .from(medIntakeLogs) + .innerJoin(medMedications, eq(medMedications.id, medIntakeLogs.medicationId)) + .where(and(eq(medIntakeLogs.id, intakeId), eq(medMedications.familyId, familyId))) + .innerJoin(medFamilyGroups, eq(medFamilyGroups.id, medMedications.familyId)); + if (!intake) return null; + + const newScheduled = new Date(Date.now() + minutes * 60 * 1000); + const [updated] = await db + .update(medIntakeLogs) + .set({ scheduledAt: newScheduled, status: "snoozed" }) + .where(eq(medIntakeLogs.id, intakeId)) + .returning(); + return updated ?? null; + } + + // ===================== + // Cabinet + // ===================== + + async upsertCabinetItem( + familyId: number, + organizationId: number, + data: Omit & { id?: number }, + addedBy: number + ) { + const group = await this.getFamilyGroupById(familyId, organizationId); + if (!group) return null; + + if (data.id) { + const [item] = await db + .update(medCabinetItems) + .set({ ...data, familyId, addedBy, updatedAt: new Date() }) + .where(and(eq(medCabinetItems.id, data.id), eq(medCabinetItems.familyId, familyId))) + .returning(); + return item ?? null; + } + + const [item] = await db + .insert(medCabinetItems) + .values({ ...data, familyId, addedBy }) + .returning(); + return item; + } + + async getCabinetItems(familyId: number, organizationId: number) { + const group = await this.getFamilyGroupById(familyId, organizationId); + if (!group) return []; + return db + .select() + .from(medCabinetItems) + .where(eq(medCabinetItems.familyId, familyId)) + .orderBy(asc(medCabinetItems.name)); + } + + async addCabinetStock( + cabinetItemId: number, + familyId: number, + organizationId: number, + change: number, + reason: string, + note: string | undefined, + changedBy: number + ) { + const group = await this.getFamilyGroupById(familyId, organizationId); + if (!group) return null; + const [item] = await db + .select() + .from(medCabinetItems) + .where(and(eq(medCabinetItems.id, cabinetItemId), eq(medCabinetItems.familyId, familyId))); + if (!item) return null; + + await db.insert(medCabinetStock).values({ + cabinetItemId, + change, + reason, + note, + changedBy, + }); + + const [updated] = await db + .update(medCabinetItems) + .set({ quantity: (item.quantity ?? 0) + change, updatedAt: new Date() }) + .where(eq(medCabinetItems.id, cabinetItemId)) + .returning(); + return updated ?? null; + } + + async deleteCabinetItem(id: number, familyId: number, organizationId: number) { + const group = await this.getFamilyGroupById(familyId, organizationId); + if (!group) return; + await db + .delete(medCabinetItems) + .where(and(eq(medCabinetItems.id, id), eq(medCabinetItems.familyId, familyId))); + } + + // ===================== + // Medical records + // ===================== + + async createMedicalRecord(data: InsertMedMedicalRecord) { + const [record] = await db.insert(medMedicalRecords).values(data).returning(); + return record; + } + + async getMedicalRecords(familyId: number, organizationId: number, memberId?: number) { + const group = await this.getFamilyGroupById(familyId, organizationId); + if (!group) return []; + const conditions = [eq(medMedicalRecords.familyId, familyId)]; + if (memberId) conditions.push(eq(medMedicalRecords.memberId, memberId)); + return db + .select() + .from(medMedicalRecords) + .where(and(...conditions)) + .orderBy(desc(medMedicalRecords.recordDate)); + } + + async updateMedicalRecord( + id: number, + familyId: number, + organizationId: number, + updates: Partial + ) { + const group = await this.getFamilyGroupById(familyId, organizationId); + if (!group) return null; + const [record] = await db + .update(medMedicalRecords) + .set({ ...updates, updatedAt: new Date() }) + .where(and(eq(medMedicalRecords.id, id), eq(medMedicalRecords.familyId, familyId))) + .returning(); + return record ?? null; + } + + async deleteMedicalRecord(id: number, familyId: number, organizationId: number) { + const group = await this.getFamilyGroupById(familyId, organizationId); + if (!group) return; + await db + .delete(medMedicalRecords) + .where(and(eq(medMedicalRecords.id, id), eq(medMedicalRecords.familyId, familyId))); + } + + // ===================== + // Daily check-ins + // ===================== + + async createDailyCheckin(data: InsertMedDailyCheckin) { + const [checkin] = await db.insert(medDailyCheckins).values(data).returning(); + return checkin; + } + + async getPendingCheckins(familyId: number, organizationId: number) { + const group = await this.getFamilyGroupById(familyId, organizationId); + if (!group) return []; + return db + .select() + .from(medDailyCheckins) + .where(and(eq(medDailyCheckins.familyId, familyId), eq(medDailyCheckins.status, "pending"))) + .orderBy(asc(medDailyCheckins.createdAt)); + } + + async submitCheckinAnswer( + id: number, + familyId: number, + organizationId: number, + answerText: string, + aiSummary?: string, + aiAlert?: string + ) { + const group = await this.getFamilyGroupById(familyId, organizationId); + if (!group) return null; + const [checkin] = await db + .update(medDailyCheckins) + .set({ + status: "answered", + answerText, + aiSummary: aiSummary ?? null, + aiAlert: aiAlert ?? null, + updatedAt: new Date(), + }) + .where(and(eq(medDailyCheckins.id, id), eq(medDailyCheckins.familyId, familyId))) + .returning(); + return checkin ?? null; + } + + // ===================== + // Bot bindings + // ===================== + + async getOrCreateBotBinding(userId: number, organizationId: number) { + const [existing] = await db + .select() + .from(medBotBindings) + .where(and(eq(medBotBindings.userId, userId), eq(medBotBindings.organizationId, organizationId))); + if (existing) return existing; + + const [binding] = await db + .insert(medBotBindings) + .values({ userId, organizationId }) + .returning(); + return binding; + } + + async getBotBindingByTelegramChatId(organizationId: number, chatId: string) { + const [binding] = await db + .select() + .from(medBotBindings) + .where(and(eq(medBotBindings.organizationId, organizationId), eq(medBotBindings.telegramChatId, chatId))); + return binding ?? null; + } + + async getBotBindingByUserId(userId: number, organizationId: number) { + const [binding] = await db + .select() + .from(medBotBindings) + .where(and(eq(medBotBindings.userId, userId), eq(medBotBindings.organizationId, organizationId))); + return binding ?? null; + } + + async getFamilyCreator(familyId: number, organizationId: number) { + const group = await this.getFamilyGroupById(familyId, organizationId); + if (!group) return null; + const [creator] = await db + .select({ id: users.id, firstName: users.firstName, lastName: users.lastName, email: users.email }) + .from(users) + .where(eq(users.id, group.createdBy)); + return creator ?? null; + } + + async getBotBindingByMaxChatId(organizationId: number, chatId: string) { + const [binding] = await db + .select() + .from(medBotBindings) + .where(and(eq(medBotBindings.organizationId, organizationId), eq(medBotBindings.maxChatId, chatId))); + return binding ?? null; + } + + async bindTelegram(userId: number, organizationId: number, chatId: string, username?: string) { + const binding = await this.getOrCreateBotBinding(userId, organizationId); + const [updated] = await db + .update(medBotBindings) + .set({ telegramChatId: chatId, telegramUsername: username ?? null, updatedAt: new Date() }) + .where(eq(medBotBindings.id, binding.id)) + .returning(); + return updated ?? binding; + } + + async bindMax(userId: number, organizationId: number, chatId: string, username?: string) { + const binding = await this.getOrCreateBotBinding(userId, organizationId); + const [updated] = await db + .update(medBotBindings) + .set({ maxChatId: chatId, maxUsername: username ?? null, updatedAt: new Date() }) + .where(eq(medBotBindings.id, binding.id)) + .returning(); + return updated ?? binding; + } + + async getFamilyMembersWithBotBindings(familyId: number, organizationId: number) { + const members = await this.getFamilyMembers(familyId, organizationId); + if (members.length === 0) return []; + const userIds = members.map((m) => m.userId).filter(Boolean) as number[]; + if (userIds.length === 0) return members.map((m) => ({ ...m, bindings: null })); + + const bindings = await db + .select() + .from(medBotBindings) + .where(and(eq(medBotBindings.organizationId, organizationId), inArray(medBotBindings.userId, userIds))); + + const bindingMap = new Map(bindings.map((b) => [b.userId, b])); + return members.map((m) => ({ ...m, bindings: m.userId ? bindingMap.get(m.userId) ?? null : null })); + } +} + +export const medScheduleStorage = new MedScheduleStorage(); diff --git a/server/medschedule/worker.ts b/server/medschedule/worker.ts new file mode 100644 index 0000000..a95e8fa --- /dev/null +++ b/server/medschedule/worker.ts @@ -0,0 +1,201 @@ +import { db } from "../db"; +import { medScheduleStorage } from "./storage"; +import { sendBotMessage } from "./bot-sender"; +import { generateDailyCheckinQuestion } from "./ai.service"; +import { withSuperAdmin } from "../db"; +import { medIntakeLogs } from "@shared/schema"; +import { eq } from "drizzle-orm"; +import type { BotPlatform } from "./bot-sender"; + +let isRunning = false; + +function toMsk(date: Date): Date { + return new Date(date.toLocaleString("en-US", { timeZone: "Europe/Moscow" })); +} + +function mskDateString(date: Date): string { + return toMsk(date).toISOString().split("T")[0]; +} + +export function startMedScheduleWorker() { + if (isRunning) return; + isRunning = true; + + // Каждую минуту — отправка напоминаний о приёме лекарств + setInterval(async () => { + try { + await withSuperAdmin(() => processReminders()); + } catch (err) { + console.error("[MedSchedule Worker] Reminder error:", err); + } + }, 60_000); + + // Каждые 15 минут — уведомления о пропущенных приёмах + setInterval(async () => { + try { + await withSuperAdmin(() => processMissedIntakes()); + } catch (err) { + console.error("[MedSchedule Worker] Missed intakes error:", err); + } + }, 15 * 60_000); + + // Каждый час — проверка сроков годности + setInterval(async () => { + try { + await withSuperAdmin(() => processExpiryAlerts()); + } catch (err) { + console.error("[MedSchedule Worker] Expiry alerts error:", err); + } + }, 60 * 60_000); + + // Ежедневный чек-ин в 9:00 по Москве + scheduleAtMskHour(9, async () => { + try { + await withSuperAdmin(() => processDailyCheckins()); + } catch (err) { + console.error("[MedSchedule Worker] Daily check-in error:", err); + } + }); + + console.log("[MedSchedule Worker] started"); +} + +async function processReminders() { + const orgId = await medScheduleStorage.ensureMedScheduleOrganization(); + const windowEnd = new Date(Date.now() + 5 * 60_000); // ближайшие 5 минут + const after = new Date(Date.now() - 60_000); // не раньше 1 минуты назад + const intakes = await medScheduleStorage.getPendingIntakes(orgId, windowEnd, after); + + for (const { intake, medication, member, family } of intakes) { + const reminded = new Set(intake.remindedVia ?? []); + if (reminded.has("telegram") && reminded.has("max")) continue; + + const target = await resolveNotificationTarget(member.userId, family.id, orgId); + if (!target) continue; + + const text = `💊 Напоминание: ${member.profileName} нужно принять ${medication.name}${medication.dosage ? ` (${medication.dosage})` : ""} в ${formatTime(intake.scheduledAt)}`; + + const result = await sendBotMessage(target.platform, target.chatId, text, { + parseMode: "HTML", + }); + + if (result.ok) { + reminded.add(target.platform); + await db + .update(medIntakeLogs) + .set({ remindedVia: Array.from(reminded) }) + .where(eq(medIntakeLogs.id, intake.id)); + } + } +} + +async function processMissedIntakes() { + const orgId = await medScheduleStorage.ensureMedScheduleOrganization(); + const missed = await medScheduleStorage.getMissedIntakes(orgId, 15); + for (const { intake, medication, member, family } of missed) { + const creator = await medScheduleStorage.getFamilyCreator(family.id, orgId); + if (!creator) continue; + const binding = await medScheduleStorage.getBotBindingByUserId(creator.id, orgId); + if (!binding?.telegramChatId && !binding?.maxChatId) continue; + + const platform: BotPlatform = binding.telegramChatId ? "telegram" : "max"; + const chatId = binding.telegramChatId || binding.maxChatId!; + const text = `⚠️ ${member.profileName} пропустил приём ${medication.name} (${formatTime(intake.scheduledAt)})`; + await sendBotMessage(platform, chatId, text); + } +} + +async function processExpiryAlerts() { + const orgId = await medScheduleStorage.ensureMedScheduleOrganization(); + const groups = await medScheduleStorage.getAllFamilyGroups(orgId); + const alertDays = 30; + const alertDate = new Date(); + alertDate.setDate(alertDate.getDate() + alertDays); + + for (const group of groups) { + const items = await medScheduleStorage.getCabinetItems(group.id, orgId); + const expiring = items.filter((i) => i.expiryDate && new Date(i.expiryDate) <= alertDate); + if (expiring.length === 0) continue; + + const creator = await medScheduleStorage.getFamilyCreator(group.id, orgId); + if (!creator) continue; + const binding = await medScheduleStorage.getBotBindingByUserId(creator.id, orgId); + if (!binding?.telegramChatId && !binding?.maxChatId) continue; + + const platform: BotPlatform = binding.telegramChatId ? "telegram" : "max"; + const chatId = binding.telegramChatId || binding.maxChatId!; + const list = expiring.map((i) => `- ${i.name} (${i.expiryDate})`).join("\n"); + const text = `📅 Истекающие лекарства:\n${list}`; + await sendBotMessage(platform, chatId, text); + } +} + +async function processDailyCheckins() { + const orgId = await medScheduleStorage.ensureMedScheduleOrganization(); + const groups = await medScheduleStorage.getAllFamilyGroups(orgId); + + for (const group of groups) { + const members = await medScheduleStorage.getFamilyMembers(group.id, orgId); + const medications = await medScheduleStorage.getMedicationsByFamily(group.id, orgId); + const records = await medScheduleStorage.getMedicalRecords(group.id, orgId); + + const generated = await generateDailyCheckinQuestion(orgId, members, medications, records); + if (!generated) continue; + + const today = mskDateString(new Date()); + await medScheduleStorage.createDailyCheckin({ + familyId: group.id, + memberId: null, + checkInDate: today, + question: generated.question, + }); + + // Отправляем вопрос админу/создателю семьи + const creator = await medScheduleStorage.getFamilyCreator(group.id, orgId); + if (!creator) continue; + const binding = await medScheduleStorage.getBotBindingByUserId(creator.id, orgId); + if (!binding?.telegramChatId && !binding?.maxChatId) continue; + + const platform: BotPlatform = binding.telegramChatId ? "telegram" : "max"; + const chatId = binding.telegramChatId || binding.maxChatId!; + await sendBotMessage(platform, chatId, `🩺 Ежедневный чек-ин: ${generated.question}\n\nОтветьте текстом.`); + } +} + +async function resolveNotificationTarget( + memberUserId: number | null, + familyId: number, + orgId: number +): Promise<{ platform: BotPlatform; chatId: string } | null> { + if (memberUserId) { + const binding = await medScheduleStorage.getBotBindingByUserId(memberUserId, orgId); + if (binding?.telegramChatId) return { platform: "telegram", chatId: binding.telegramChatId }; + if (binding?.maxChatId) return { platform: "max", chatId: binding.maxChatId }; + } + + const creator = await medScheduleStorage.getFamilyCreator(familyId, orgId); + if (!creator) return null; + const binding = await medScheduleStorage.getBotBindingByUserId(creator.id, orgId); + if (binding?.telegramChatId) return { platform: "telegram", chatId: binding.telegramChatId }; + if (binding?.maxChatId) return { platform: "max", chatId: binding.maxChatId }; + return null; +} + +function formatTime(date: Date): string { + return date.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit", timeZone: "Europe/Moscow" }); +} + +function scheduleAtMskHour(hour: number, fn: () => Promise) { + const now = new Date(); + const mskNow = toMsk(now); + const target = new Date(mskNow); + target.setHours(hour, 0, 0, 0); + if (target <= mskNow) { + target.setDate(target.getDate() + 1); + } + const ms = target.getTime() - mskNow.getTime(); + setTimeout(() => { + fn().catch(console.error); + setInterval(() => fn().catch(console.error), 24 * 60 * 60 * 1000); + }, ms); +} diff --git a/server/routes/index.ts b/server/routes/index.ts index a6dfd3c..8fe9d85 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -38,6 +38,8 @@ import { calendarRouter } from "./calendar.routes"; import { ganttRouter } from "./gantt.routes"; import organizationSkinsRouter from "./organization-skins.routes"; import { registerSyncRoutes } from "./sync.routes"; +import { registerMedScheduleRoutes } from "../medschedule/routes"; +import { registerMedScheduleBotRoutes } from "../medschedule/bot.routes"; import { FIELD_TYPE_DEFINITIONS } from "../../shared/field-types"; export { publishNotificationSSE }; @@ -113,6 +115,8 @@ export async function registerRoutes(app: Express): Promise { app.use(ganttRouter); app.use(organizationSkinsRouter); registerSyncRoutes(app); + registerMedScheduleRoutes(app); + registerMedScheduleBotRoutes(app); const httpServer = createServer(app); return httpServer; diff --git a/server/services/embedding.service.ts b/server/services/embedding.service.ts index 40a4977..76496e2 100644 --- a/server/services/embedding.service.ts +++ b/server/services/embedding.service.ts @@ -101,7 +101,7 @@ async function doEmbeddingBatch(texts: string[], config: LlmProviderConfig): Pro return generateEmbeddingBatch(texts, config); } -async function resolveChatConfig(organizationId: number): Promise { +export async function resolveChatConfig(organizationId: number): Promise { const settings = await storage.getRagSettings(organizationId); if (settings) { // chatProviderId — отдельный провайдер для суммаризации; diff --git a/shared/schema.ts b/shared/schema.ts index 7f94125..a81d8a4 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -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>(), + 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;