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;