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,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);
}