feat(backend): add intake journal APIs and share note support

This commit is contained in:
Daniel Volz
2026-05-24 13:36:25 +02:00
committed by GitHub
parent 767ae23843
commit e4a1b449c6
28 changed files with 6384 additions and 281 deletions
@@ -0,0 +1,90 @@
import { eq } from "drizzle-orm";
import { db } from "../db/client.js";
import { intakeJournal } from "../db/schema.js";
type IntakeJournalWriteDatabase = Pick<typeof db, "insert">;
export type IntakeJournalExportPayload = {
journalNote: string;
journalCreatedAt?: string | null;
journalUpdatedAt?: string | null;
};
function toIsoStringOrNull(value: Date | string | number | null | undefined): string | null {
if (!value) {
return null;
}
try {
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value.toISOString();
}
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
} catch {
return null;
}
}
function toDateOrFallback(value: string | null | undefined, fallback: Date): Date {
if (!value) {
return fallback;
}
try {
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? fallback : parsed;
} catch {
return fallback;
}
}
export async function listIntakeJournalExportPayloadsForUser(
userId: number
): Promise<Map<number, IntakeJournalExportPayload>> {
const rows = await db.select().from(intakeJournal).where(eq(intakeJournal.userId, userId));
return new Map(
rows.map((row) => [
row.doseTrackingId,
{
journalNote: row.note,
journalCreatedAt: toIsoStringOrNull(row.createdAt),
journalUpdatedAt: toIsoStringOrNull(row.updatedAt),
},
])
);
}
export async function restoreIntakeJournalForImportedDose(input: {
userId: number;
doseTrackingId: number;
medicationId: number;
scheduledFor: Date;
journalNote?: string | null;
journalCreatedAt?: string | null;
journalUpdatedAt?: string | null;
database?: IntakeJournalWriteDatabase;
}): Promise<boolean> {
const normalizedNote = input.journalNote?.trim() ?? "";
if (normalizedNote.length === 0) {
return false;
}
const createdAt = toDateOrFallback(input.journalCreatedAt, input.scheduledFor);
const updatedAt = toDateOrFallback(input.journalUpdatedAt, createdAt);
const database = input.database ?? db;
await database.insert(intakeJournal).values({
userId: input.userId,
doseTrackingId: input.doseTrackingId,
medicationId: input.medicationId,
scheduledFor: input.scheduledFor,
note: normalizedNote,
createdAt,
updatedAt,
});
return true;
}
@@ -0,0 +1,332 @@
import { and, desc, eq, gte, lte } from "drizzle-orm";
import { db } from "../db/client.js";
import { doseTracking, intakeJournal, medications } from "../db/schema.js";
import { parseIntakesJson, parseLocalDateTime } from "../utils/scheduler-utils.js";
import type { DoseTrackingSource } from "./dose-tracking-service.js";
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
type ParsedDoseId = {
medicationId: number;
intakeIndex: number;
timestampMs: number;
personSuffix: string | null;
};
type MedicationTimingRow = {
id: number;
name: string | null;
genericName: string | null;
intakesJson: string;
usageJson: string;
everyJson: string;
startJson: string;
intakeRemindersEnabled: boolean;
};
export type ResolvedTrackedDoseEvent = {
doseTrackingId: number;
userId: number;
doseId: string;
medicationId: number;
medicationName: string;
scheduledFor: Date;
takenAt: Date;
markedBy: string | null;
takenSource: DoseTrackingSource;
dismissed: boolean;
personSuffix: string | null;
};
export type IntakeJournalEntry = typeof intakeJournal.$inferSelect;
export type IntakeJournalHistoryEntry = {
id: number;
doseTrackingId: number;
doseId: string;
medicationId: number;
medicationName: string;
scheduledFor: Date;
takenAt: Date;
markedBy: string | null;
takenSource: DoseTrackingSource;
dismissed: boolean;
note: string;
createdAt: Date;
updatedAt: Date;
};
function parseDoseId(doseId: string): ParsedDoseId | null {
const match = doseIdPattern.exec(doseId);
if (!match) {
return null;
}
const medicationId = Number.parseInt(match[1], 10);
const intakeIndex = Number.parseInt(match[2], 10);
const timestampMs = Number.parseInt(match[3], 10);
const personSuffix = match[4] ? match[4].trim() : null;
if (Number.isNaN(medicationId) || Number.isNaN(intakeIndex) || Number.isNaN(timestampMs) || intakeIndex < 0) {
return null;
}
return {
medicationId,
intakeIndex,
timestampMs,
personSuffix,
};
}
export function isTrackedDoseIdFormat(doseId: string): boolean {
return parseDoseId(doseId) !== null;
}
function getMedicationDisplayName(medication: Pick<MedicationTimingRow, "id" | "name" | "genericName">): string {
const commercialName = medication.name?.trim() ?? "";
if (commercialName.length > 0) {
return commercialName;
}
const genericName = medication.genericName?.trim() ?? "";
if (genericName.length > 0) {
return genericName;
}
return `Medication #${medication.id}`;
}
function resolveScheduledFor(parsedDose: ParsedDoseId, medication: MedicationTimingRow): Date {
const intakes = parseIntakesJson(
medication.intakesJson,
{
usageJson: medication.usageJson,
everyJson: medication.everyJson,
startJson: medication.startJson,
},
medication.intakeRemindersEnabled
);
const intake = intakes[parsedDose.intakeIndex];
if (!intake) {
return new Date(parsedDose.timestampMs);
}
const doseDate = new Date(parsedDose.timestampMs);
const intakeStart = parseLocalDateTime(intake.start);
return new Date(
doseDate.getFullYear(),
doseDate.getMonth(),
doseDate.getDate(),
intakeStart.getHours(),
intakeStart.getMinutes(),
intakeStart.getSeconds(),
intakeStart.getMilliseconds()
);
}
export async function resolveTrackedDoseEventForUser(input: {
userId: number;
doseId: string;
}): Promise<ResolvedTrackedDoseEvent | null> {
const parsedDose = parseDoseId(input.doseId);
if (!parsedDose) {
return null;
}
const [event] = await db
.select({
doseTrackingId: doseTracking.id,
userId: doseTracking.userId,
doseId: doseTracking.doseId,
takenAt: doseTracking.takenAt,
markedBy: doseTracking.markedBy,
takenSource: doseTracking.takenSource,
dismissed: doseTracking.dismissed,
medicationId: medications.id,
medicationName: medications.name,
medicationGenericName: medications.genericName,
intakesJson: medications.intakesJson,
usageJson: medications.usageJson,
everyJson: medications.everyJson,
startJson: medications.startJson,
intakeRemindersEnabled: medications.intakeRemindersEnabled,
})
.from(doseTracking)
.innerJoin(medications, and(eq(medications.id, parsedDose.medicationId), eq(medications.userId, input.userId)))
.where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, input.doseId)))
.limit(1);
if (!event) {
return null;
}
const scheduledFor = resolveScheduledFor(parsedDose, {
id: event.medicationId,
name: event.medicationName,
genericName: event.medicationGenericName,
intakesJson: event.intakesJson,
usageJson: event.usageJson,
everyJson: event.everyJson,
startJson: event.startJson,
intakeRemindersEnabled: event.intakeRemindersEnabled ?? false,
});
return {
doseTrackingId: event.doseTrackingId,
userId: event.userId,
doseId: event.doseId,
medicationId: event.medicationId,
medicationName: getMedicationDisplayName({
id: event.medicationId,
name: event.medicationName,
genericName: event.medicationGenericName,
}),
scheduledFor,
takenAt: event.takenAt,
markedBy: event.markedBy,
takenSource: event.takenSource as DoseTrackingSource,
dismissed: event.dismissed ?? false,
personSuffix: parsedDose.personSuffix,
};
}
export async function getIntakeJournalForDoseEvent(input: {
userId: number;
doseId: string;
}): Promise<IntakeJournalEntry | null> {
const event = await resolveTrackedDoseEventForUser(input);
if (!event) {
return null;
}
const [journalEntry] = await db
.select()
.from(intakeJournal)
.where(and(eq(intakeJournal.userId, input.userId), eq(intakeJournal.doseTrackingId, event.doseTrackingId)))
.limit(1);
return journalEntry ?? null;
}
export async function upsertIntakeJournalForDoseEvent(input: {
userId: number;
doseId: string;
note: string;
}): Promise<IntakeJournalEntry | null> {
const normalizedNote = input.note.trim();
if (normalizedNote.length === 0) {
await deleteIntakeJournalForDoseEvent({ userId: input.userId, doseId: input.doseId });
return null;
}
const event = await resolveTrackedDoseEventForUser({ userId: input.userId, doseId: input.doseId });
if (!event) {
return null;
}
const now = new Date();
await db
.insert(intakeJournal)
.values({
userId: input.userId,
doseTrackingId: event.doseTrackingId,
medicationId: event.medicationId,
scheduledFor: event.scheduledFor,
note: normalizedNote,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: intakeJournal.doseTrackingId,
set: {
userId: input.userId,
medicationId: event.medicationId,
note: normalizedNote,
updatedAt: now,
},
});
return getIntakeJournalForDoseEvent({ userId: input.userId, doseId: input.doseId });
}
export async function deleteIntakeJournalForDoseEvent(input: { userId: number; doseId: string }): Promise<boolean> {
const event = await resolveTrackedDoseEventForUser(input);
if (!event) {
return false;
}
await db
.delete(intakeJournal)
.where(and(eq(intakeJournal.userId, input.userId), eq(intakeJournal.doseTrackingId, event.doseTrackingId)));
return true;
}
export async function listIntakeJournalEntriesForUser(input: {
userId: number;
medicationId?: number;
from?: Date;
to?: Date;
limit?: number;
}): Promise<IntakeJournalHistoryEntry[]> {
const filters = [eq(intakeJournal.userId, input.userId)];
if (typeof input.medicationId === "number") {
filters.push(eq(intakeJournal.medicationId, input.medicationId));
}
if (input.from) {
filters.push(gte(intakeJournal.scheduledFor, input.from));
}
if (input.to) {
filters.push(lte(intakeJournal.scheduledFor, input.to));
}
const rows = await db
.select({
id: intakeJournal.id,
doseTrackingId: intakeJournal.doseTrackingId,
doseId: doseTracking.doseId,
medicationId: intakeJournal.medicationId,
medicationName: medications.name,
medicationGenericName: medications.genericName,
scheduledFor: intakeJournal.scheduledFor,
takenAt: doseTracking.takenAt,
markedBy: doseTracking.markedBy,
takenSource: doseTracking.takenSource,
dismissed: doseTracking.dismissed,
note: intakeJournal.note,
createdAt: intakeJournal.createdAt,
updatedAt: intakeJournal.updatedAt,
})
.from(intakeJournal)
.innerJoin(doseTracking, eq(doseTracking.id, intakeJournal.doseTrackingId))
.innerJoin(medications, eq(medications.id, intakeJournal.medicationId))
.where(and(...filters))
.orderBy(desc(intakeJournal.scheduledFor), desc(intakeJournal.updatedAt))
.limit(input.limit ?? 100);
return rows.map((row) => ({
id: row.id,
doseTrackingId: row.doseTrackingId,
doseId: row.doseId,
medicationId: row.medicationId,
medicationName: getMedicationDisplayName({
id: row.medicationId,
name: row.medicationName,
genericName: row.medicationGenericName,
}),
scheduledFor: row.scheduledFor,
takenAt: row.takenAt,
markedBy: row.markedBy,
takenSource: row.takenSource as DoseTrackingSource,
dismissed: row.dismissed ?? false,
note: row.note,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}));
}