import { and, eq } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import { db } from "../db/client.js"; import { doseTracking, medications, shareTokens, userSettings } from "../db/schema.js"; import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; import { env } from "../plugins/env.js"; import { computeMedicationCurrentStock } from "../services/current-stock.js"; import type { AuthUser } from "../types/fastify.js"; import { applyOpenApiRouteStandards, genericErrorSchema, tokenParamsSchema, validationErrorSchema, } from "../utils/openapi-route-standards.js"; import { parseIntakesJson, parseLocalDateTime, parseTakenByJson, personTakesMedication, } from "../utils/scheduler-utils.js"; // ============================================================================= // Validation Schemas // ============================================================================= const markDoseSchema = z.object({ doseId: z.string().min(1, "doseId is required"), }); const shareDoseSchema = z.object({ doseId: z.string().min(1, "doseId is required"), }); const dismissDosesSchema = z.object({ doseIds: z.array(z.string().min(1)).min(1, "At least one doseId is required"), }); const protectedEndpointSecurity: ReadonlyArray> = [ { bearerAuth: [] }, { cookieAuth: [] }, ]; const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/; const doseReadResponseSchema = { type: "object", properties: { doses: { type: "array", items: { type: "object", properties: { doseId: { type: "string" }, takenAt: { type: "number" }, markedBy: { type: ["string", "null"] }, takenSource: { type: "string" }, dismissed: { type: "boolean" }, }, }, }, }, } as const; // Helper to get user ID from request // Returns anonymous user ID when auth is disabled async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise { // If auth is disabled, use the anonymous user if (!env.AUTH_ENABLED) { return getAnonymousUserId(); } const authUser = request.user as unknown as AuthUser | null; if (!authUser) { reply.status(401).send({ error: "Not authenticated" }); throw new Error("AUTH_REQUIRED"); } return authUser.id; } type ParsedDoseId = { medicationId: number; intakeIndex: number; timestampMs: number; personSuffix: string | null; }; 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, }; } async function getActiveShareToken(token: string): Promise<{ share: typeof shareTokens.$inferSelect | null; reason: "not_found" | "expired" | "ok"; }> { const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token)); if (!share) return { share: null, reason: "not_found" }; if (share.expiresAt && share.expiresAt.getTime() < Date.now()) { return { share: null, reason: "expired" }; } return { share, reason: "ok" }; } async function validateShareDoseId(share: typeof shareTokens.$inferSelect, doseId: string): Promise { const parsedDose = parseDoseId(doseId); if (!parsedDose) { return false; } const [medication] = await db .select() .from(medications) .where(and(eq(medications.id, parsedDose.medicationId), eq(medications.userId, share.userId))); if (!medication) { return false; } const medTakenBy = parseTakenByJson(medication.takenByJson); const intakes = parseIntakesJson( medication.intakesJson, { usageJson: medication.usageJson, everyJson: medication.everyJson, startJson: medication.startJson }, medication.intakeRemindersEnabled ?? false ); if (!personTakesMedication(share.takenBy, medTakenBy, intakes)) { return false; } const intake = intakes[parsedDose.intakeIndex]; if (!intake) { return false; } const expectedPersons = intake.takenBy ? [intake.takenBy] : medTakenBy; if (expectedPersons.length === 0) { return parsedDose.personSuffix === null; } if (!parsedDose.personSuffix) { return intake.takenBy === null; } return expectedPersons.includes(parsedDose.personSuffix); } async function isDoseOutOfStock(options: { userId: number; doseId: string; stockCalculationMode: "automatic" | "manual"; }): Promise { const parsedDose = parseDoseId(options.doseId); if (!parsedDose) { return false; } const [medication] = await db .select() .from(medications) .where(and(eq(medications.id, parsedDose.medicationId), eq(medications.userId, options.userId))); if (!medication) { return false; } const intakes = parseIntakesJson( medication.intakesJson, { usageJson: medication.usageJson, everyJson: medication.everyJson, startJson: medication.startJson }, medication.intakeRemindersEnabled ?? false ); const intake = intakes[parsedDose.intakeIndex]; const scheduledOccurrenceMs = intake ? (() => { 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() ).getTime(); })() : parsedDose.timestampMs; const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, options.userId)); const stockBeforeDoseMs = Math.max(0, scheduledOccurrenceMs - 1); return ( computeMedicationCurrentStock({ medication, doses, stockCalculationMode: options.stockCalculationMode, nowMs: stockBeforeDoseMs, }) <= 0 ); } // ============================================================================= // Dose Tracking Routes // ============================================================================= export async function doseRoutes(app: FastifyInstance) { applyOpenApiRouteStandards(app, { tag: "doses", protectedByDefault: false, protectedPaths: [/^\/doses\/taken$/, /^\/doses\/taken\/:doseId$/, /^\/doses\/dismiss$/], }); // --------------------------------------------------------------------------- // GET /doses/taken - PROTECTED: Get all taken doses for the user // Suppress request logs — polled every 5s by frontend // --------------------------------------------------------------------------- app.get( "/doses/taken", { preHandler: requireAuth, logLevel: "warn", schema: { tags: ["doses"], security: protectedEndpointSecurity, response: { 200: doseReadResponseSchema, 401: genericErrorSchema, }, }, }, async (request, reply) => { const userId = await getUserId(request, reply); // Get all taken doses for this user (no time limit) const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId)); return { doses: doses.map((d) => ({ doseId: d.doseId, takenAt: d.takenAt?.getTime() ?? Date.now(), markedBy: d.markedBy, takenSource: d.takenSource ?? "manual", dismissed: d.dismissed ?? false, })), }; } ); // --------------------------------------------------------------------------- // POST /doses/taken - PROTECTED: Mark a dose as taken // --------------------------------------------------------------------------- app.post<{ Body: z.infer }>( "/doses/taken", { preHandler: requireAuth, schema: { tags: ["doses"], security: protectedEndpointSecurity, body: { type: "object", properties: { doseId: { type: "string" }, }, example: { doseId: "1:2026-03-11T08:00:00.000Z:Daniel", }, }, response: { 200: { type: "object", properties: { success: { type: "boolean" }, message: { type: "string" }, }, }, 400: { anyOf: [genericErrorSchema, validationErrorSchema] }, 409: genericErrorSchema, 401: genericErrorSchema, }, }, }, async (request, reply) => { const userId = await getUserId(request, reply); const parsed = markDoseSchema.safeParse(request.body); if (!parsed.success) { return reply.status(400).send({ error: parsed.error.errors[0]?.message ?? "Invalid input", }); } const { doseId } = parsed.data; // Check if already marked const [existing] = await db .select() .from(doseTracking) .where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId))); if (existing) { return { success: true, message: "Already marked" }; } const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId)); const outOfStock = await isDoseOutOfStock({ userId, doseId, stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic", }); if (outOfStock) { return reply.status(409).send({ error: "Medication is out of stock", code: "OUT_OF_STOCK" }); } // Insert new record await db.insert(doseTracking).values({ userId, doseId, markedBy: null, // Marked by the user themselves takenSource: "manual", }); return { success: true }; } ); // --------------------------------------------------------------------------- // DELETE /doses/taken/:doseId - PROTECTED: Unmark a dose // --------------------------------------------------------------------------- app.delete<{ Params: { doseId: string } }>( "/doses/taken/:doseId", { preHandler: requireAuth, schema: { tags: ["doses"], security: protectedEndpointSecurity, params: { type: "object", required: ["doseId"], properties: { doseId: { type: "string", minLength: 1 }, }, }, response: { 200: { type: "object", properties: { success: { type: "boolean" } } }, 401: genericErrorSchema, }, }, }, async (request, reply) => { const userId = await getUserId(request, reply); const { doseId } = request.params; // Check if this dose was dismissed const [existing] = await db .select() .from(doseTracking) .where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId))); if (existing?.dismissed) { // Already dismissed - keep the record as-is // The dose stays dismissed, we just acknowledge the undo request } else { // Not dismissed - delete the record entirely await db.delete(doseTracking).where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId))); } return { success: true }; } ); // --------------------------------------------------------------------------- // POST /doses/dismiss - PROTECTED: Dismiss missed doses without deducting stock // --------------------------------------------------------------------------- app.post<{ Body: z.infer }>( "/doses/dismiss", { preHandler: requireAuth, schema: { tags: ["doses"], security: protectedEndpointSecurity, body: { type: "object", properties: { doseIds: { type: "array", items: { type: "string" } }, }, example: { doseIds: ["1:2026-03-11T08:00:00.000Z:Daniel", "1:2026-03-11T20:00:00.000Z:Daniel"], }, }, response: { 200: { type: "object", properties: { success: { type: "boolean" }, dismissedCount: { type: "integer" }, }, }, 400: { anyOf: [genericErrorSchema, validationErrorSchema] }, 401: genericErrorSchema, }, }, }, async (request, reply) => { const userId = await getUserId(request, reply); const parsed = dismissDosesSchema.safeParse(request.body); if (!parsed.success) { return reply.status(400).send({ error: parsed.error.errors[0]?.message ?? "Invalid input", }); } const { doseIds } = parsed.data; // Insert dismissed records for each dose that doesn't exist yet let dismissedCount = 0; for (const doseId of doseIds) { // Check if already exists (taken or dismissed) const [existing] = await db .select() .from(doseTracking) .where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId))); if (existing) { // Already exists - update to dismissed if not already if (!existing.dismissed) { await db .update(doseTracking) .set({ dismissed: true }) .where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId))); dismissedCount++; } } else { // Create new dismissed record await db.insert(doseTracking).values({ userId, doseId, markedBy: null, takenAt: new Date(0), dismissed: true, }); dismissedCount++; } } return { success: true, dismissedCount }; } ); // --------------------------------------------------------------------------- // DELETE /doses/dismiss - PROTECTED: Clear all dismissed doses (un-dismiss) // --------------------------------------------------------------------------- app.delete( "/doses/dismiss", { preHandler: requireAuth, schema: { tags: ["doses"], security: protectedEndpointSecurity, response: { 200: { type: "object", properties: { success: { type: "boolean" }, clearedCount: { type: "integer" }, }, }, 401: genericErrorSchema, }, }, }, async (request, reply) => { const userId = await getUserId(request, reply); // Delete all dismissed-only records (not taken ones) // For taken+dismissed, just remove the dismissed flag const dismissed = await db .select() .from(doseTracking) .where(and(eq(doseTracking.userId, userId), eq(doseTracking.dismissed, true))); for (const d of dismissed) { const hasRealTakenTimestamp = d.takenAt instanceof Date ? d.takenAt.getTime() > 0 : Boolean(d.takenAt); if (d.markedBy !== null || hasRealTakenTimestamp) { // This was also marked as taken - just remove dismissed flag await db.update(doseTracking).set({ dismissed: false }).where(eq(doseTracking.id, d.id)); } else { // This was only dismissed - delete it await db.delete(doseTracking).where(eq(doseTracking.id, d.id)); } } return { success: true, clearedCount: dismissed.length }; } ); // --------------------------------------------------------------------------- // GET /share/:token/doses - PUBLIC: Get taken doses for a share link // Suppress request logs — polled every 5s by SharedSchedule // --------------------------------------------------------------------------- app.get<{ Params: { token: string } }>( "/share/:token/doses", { schema: { params: tokenParamsSchema, response: { 200: doseReadResponseSchema, 404: genericErrorSchema, }, }, logLevel: "warn", config: { rateLimit: { max: 60, timeWindow: "1 minute", errorResponseBuilder: () => ({ error: "rate_limited" }), }, }, }, async (request, reply) => { const { token } = request.params; const { share, reason } = await getActiveShareToken(token); if (!share) { request.log.warn(`[ShareDose] Rejected read: token=${token}, reason=${reason}`); return reply.notFound("Share link not found"); } // Get all taken doses for this user (no time limit) const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId)); return { doses: doses.map((d) => ({ doseId: d.doseId, takenAt: d.takenAt?.getTime() ?? Date.now(), markedBy: d.markedBy, takenSource: d.takenSource ?? "manual", dismissed: d.dismissed ?? false, })), }; } ); // --------------------------------------------------------------------------- // POST /share/:token/doses - PUBLIC: Mark a dose as taken via share link // --------------------------------------------------------------------------- app.post<{ Params: { token: string }; Body: z.infer }>( "/share/:token/doses", { schema: { params: tokenParamsSchema, body: { type: "object", properties: { doseId: { type: "string" }, }, example: { doseId: "1:2026-03-11T08:00:00.000Z:Daniel", }, }, response: { 200: { type: "object", properties: { success: { type: "boolean" }, message: { type: "string" } } }, 400: { anyOf: [genericErrorSchema, validationErrorSchema] }, 409: genericErrorSchema, 404: genericErrorSchema, }, }, }, async (request, reply) => { const { token } = request.params; const parsed = shareDoseSchema.safeParse(request.body); if (!parsed.success) { return reply.status(400).send({ error: parsed.error.errors[0]?.message ?? "Invalid input", }); } const { doseId } = parsed.data; const { share, reason } = await getActiveShareToken(token); if (!share) { request.log.warn(`[ShareDose] Rejected mark: token=${token}, doseId=${doseId}, reason=${reason}`); return reply.notFound("Share link not found"); } const isValidShareDoseId = await validateShareDoseId(share, doseId); if (!isValidShareDoseId) { request.log.warn( `[ShareDose] Rejected invalid doseId in mark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}` ); return reply.status(400).send({ error: "Invalid or unauthorized doseId" }); } // Check if already marked const [existing] = await db .select() .from(doseTracking) .where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId))); if (existing) { request.log.debug( `[ShareDose] Duplicate mark ignored: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}` ); return { success: true, message: "Already marked" }; } const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, share.userId)); const outOfStock = await isDoseOutOfStock({ userId: share.userId, doseId, stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic", }); if (outOfStock) { request.log.info( `[ShareDose] Rejected out-of-stock mark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}` ); return reply.status(409).send({ error: "Medication is out of stock", code: "OUT_OF_STOCK" }); } // Insert new record - marked by the shared person, or the concrete intake person for an "all" link. const parsedShareDose = parseDoseId(doseId); const markedBy = share.takenBy === "all" ? (parsedShareDose?.personSuffix ?? share.takenBy) : share.takenBy; await db.insert(doseTracking).values({ userId: share.userId, doseId, markedBy, takenSource: "manual", }); request.log.info( `[ShareDose] Dose marked via share link: token=${token}, ownerUserId=${share.userId}, shareTakenBy=${share.takenBy}, markedBy=${markedBy}, doseId=${doseId}` ); return { success: true }; } ); // --------------------------------------------------------------------------- // DELETE /share/:token/doses/:doseId - PUBLIC: Unmark a dose via share link // --------------------------------------------------------------------------- app.delete<{ Params: { token: string; doseId: string } }>( "/share/:token/doses/:doseId", { schema: { params: { type: "object", required: ["token", "doseId"], properties: { token: tokenParamsSchema.properties.token, doseId: { type: "string", minLength: 1 }, }, }, response: { 200: { type: "object", properties: { success: { type: "boolean" } } }, 400: genericErrorSchema, 404: genericErrorSchema, }, }, }, async (request, reply) => { const { token, doseId } = request.params; const { share, reason } = await getActiveShareToken(token); if (!share) { request.log.warn(`[ShareDose] Rejected unmark: token=${token}, doseId=${doseId}, reason=${reason}`); return reply.notFound("Share link not found"); } const isValidShareDoseId = await validateShareDoseId(share, doseId); if (!isValidShareDoseId) { request.log.warn( `[ShareDose] Rejected invalid doseId in unmark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}` ); return reply.status(400).send({ error: "Invalid or unauthorized doseId" }); } // Check if this dose was dismissed const [existing] = await db .select() .from(doseTracking) .where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId))); if (existing?.dismissed) { // Already dismissed - keep the record as-is request.log.debug( `[ShareDose] Unmark ignored for dismissed dose: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}` ); } else { // Not dismissed - delete the record entirely await db .delete(doseTracking) .where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId))); request.log.info( `[ShareDose] Dose unmarked via share link: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}` ); } return { success: true }; } ); }