feat: MedSchedule модуль — семейный медицинский помощник с Telegram/MAX ботом, n8n OCR и /med UI
This commit is contained in:
201
server/medschedule/worker.ts
Normal file
201
server/medschedule/worker.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user