feat: MedSchedule модуль — семейный медицинский помощник с Telegram/MAX ботом, n8n OCR и /med UI
This commit is contained in:
289
server/medschedule/ai.service.ts
Normal file
289
server/medschedule/ai.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
90
server/medschedule/bot-sender.ts
Normal file
90
server/medschedule/bot-sender.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
488
server/medschedule/bot.routes.ts
Normal file
488
server/medschedule/bot.routes.ts
Normal 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 });
|
||||
});
|
||||
}
|
||||
473
server/medschedule/routes.ts
Normal file
473
server/medschedule/routes.ts
Normal 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);
|
||||
}
|
||||
696
server/medschedule/storage.ts
Normal file
696
server/medschedule/storage.ts
Normal 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();
|
||||
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