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

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

View File

@@ -0,0 +1,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);
}