Files
iistwin/server/medschedule/storage.ts

697 lines
23 KiB
TypeScript

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();