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

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

View File

@@ -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<string | null> {
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<N8nOcrResult | null> {
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<ParsedMedication | null> {
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<ParsedMedication>(raw);
}
export async function parsePurchaseFromText(
organizationId: number,
text: string
): Promise<ParsedPurchase | null> {
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<ParsedPurchase>(raw);
}
export async function parseMedicalRecordFromText(
organizationId: number,
text: string
): Promise<ParsedMedicalRecord | null> {
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<ParsedMedicalRecord>(raw);
}
export async function generateDailyCheckinQuestion(
organizationId: number,
members: MedFamilyMember[],
medications: MedMedication[],
recentRecords: MedMedicalRecord[]
): Promise<DailyQuestionResult | null> {
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<DailyQuestionResult>(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<CheckinSummaryResult | null> {
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<CheckinSummaryResult>(raw);
if (!parsed) return null;
return {
summary: parsed.summary || answer.slice(0, 200),
alert: parsed.alert || undefined,
};
}
function safeJsonParse<T>(text: string): T | null {
try {
const cleaned = text
.replace(/^```json\s*/, "")
.replace(/```\s*$/, "")
.trim();
return JSON.parse(cleaned) as T;
} catch {
return null;
}
}

View File

@@ -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<string, any> = {
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<string, any> = {
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" };
}
}

View File

@@ -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<any> {
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<string | undefined> {
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 });
});
}

View File

@@ -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<number> {
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);
}

View File

@@ -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<number> {
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<InsertMedFamilyGroup, "inviteCode">) {
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<number, typeof owned[number]>();
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<InsertMedFamilyGroup>) {
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<InsertMedFamilyMember>) {
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<InsertMedMedication>) {
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<InsertMedCabinetItem, "familyId" | "addedBy" | "id"> & { 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<InsertMedMedicalRecord>
) {
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();

View File

@@ -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<void>) {
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);
}