diff --git a/backend/src/index.ts b/backend/src/index.ts index 6461c9d..d98c8f8 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -30,6 +30,7 @@ import { settingsRoutes } from "./routes/settings.js"; import { shareRoutes } from "./routes/share.js"; import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js"; import { startReminderScheduler } from "./services/reminder-scheduler.js"; +import { documentationSchemaAjv } from "./utils/documentation-schema-keywords.js"; // Re-export utilities from server-config for external use export { @@ -156,6 +157,7 @@ export async function createApp(options?: { const app = Fastify({ logger: buildLoggerOptions(opts.logLevel), genReqId: (request) => sanitizeCorrelationId(request.headers) ?? randomUUID(), + ajv: documentationSchemaAjv, }); app.addHook("onRequest", (request, reply, done) => { @@ -231,6 +233,7 @@ const imagesDir = ensureImagesDirectory(); const app = Fastify({ logger: buildLoggerOptions(env.LOG_LEVEL), genReqId: (request) => sanitizeCorrelationId(request.headers) ?? randomUUID(), + ajv: documentationSchemaAjv, }); app.addHook("onRequest", (request, reply, done) => { diff --git a/backend/src/routes/api-keys.ts b/backend/src/routes/api-keys.ts index ec0a180..0dbb8fe 100644 --- a/backend/src/routes/api-keys.ts +++ b/backend/src/routes/api-keys.ts @@ -167,6 +167,11 @@ export async function apiKeyRoutes(app: FastifyInstance) { scope: { type: "string", enum: ["read", "write"], default: "write" }, expiresInDays: { type: "number", minimum: 1, maximum: 3650 }, }, + example: { + name: "Home Assistant integration", + scope: "write", + expiresInDays: 365, + }, }, response: { 201: { diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index c5d249e..9f50e4d 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -176,6 +176,10 @@ export async function authRoutes(app: FastifyInstance) { username: { type: "string", minLength: 3, maxLength: 50 }, password: { type: "string", minLength: 8, maxLength: 128 }, }, + example: { + username: "daniel", + password: "correct-horse-battery-staple", + }, }, response: { 201: { @@ -274,6 +278,11 @@ export async function authRoutes(app: FastifyInstance) { password: { type: "string" }, rememberMe: { type: "boolean" }, }, + example: { + username: "daniel", + password: "correct-horse-battery-staple", + rememberMe: true, + }, }, response: { 200: { @@ -577,6 +586,10 @@ export async function authRoutes(app: FastifyInstance) { currentPassword: { type: "string" }, newPassword: { type: "string", minLength: 8, maxLength: 128 }, }, + example: { + currentPassword: "current-password", + newPassword: "new-strong-password", + }, }, response: { 200: { diff --git a/backend/src/routes/doses.ts b/backend/src/routes/doses.ts index 7a09301..cb57176 100644 --- a/backend/src/routes/doses.ts +++ b/backend/src/routes/doses.ts @@ -6,7 +6,12 @@ import { doseTracking, medications, shareTokens } from "../db/schema.js"; import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; import { env } from "../plugins/env.js"; import type { AuthUser } from "../types/fastify.js"; -import { applyOpenApiRouteStandards } from "../utils/openapi-route-standards.js"; +import { + applyOpenApiRouteStandards, + genericErrorSchema, + tokenParamsSchema, + validationErrorSchema, +} from "../utils/openapi-route-standards.js"; import { parseIntakesJson, parseTakenByJson, personTakesMedication } from "../utils/scheduler-utils.js"; // ============================================================================= @@ -31,6 +36,25 @@ const protectedEndpointSecurity: ReadonlyArray 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; + function maskToken(token: string): string { if (token.length <= 8) return token; return `${token.slice(0, 4)}...${token.slice(-4)}`; @@ -153,7 +177,18 @@ export async function doseRoutes(app: FastifyInstance) { // --------------------------------------------------------------------------- app.get( "/doses/taken", - { preHandler: requireAuth, logLevel: "warn", schema: { tags: ["doses"], security: protectedEndpointSecurity } }, + { + preHandler: requireAuth, + logLevel: "warn", + schema: { + tags: ["doses"], + security: protectedEndpointSecurity, + response: { + 200: doseReadResponseSchema, + 401: genericErrorSchema, + }, + }, + }, async (request, reply) => { const userId = await getUserId(request, reply); @@ -177,7 +212,33 @@ export async function doseRoutes(app: FastifyInstance) { // --------------------------------------------------------------------------- app.post<{ Body: z.infer }>( "/doses/taken", - { preHandler: requireAuth, schema: { tags: ["doses"], security: protectedEndpointSecurity } }, + { + 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] }, + 401: genericErrorSchema, + }, + }, + }, async (request, reply) => { const userId = await getUserId(request, reply); @@ -217,7 +278,24 @@ export async function doseRoutes(app: FastifyInstance) { // --------------------------------------------------------------------------- app.delete<{ Params: { doseId: string } }>( "/doses/taken/:doseId", - { preHandler: requireAuth, schema: { tags: ["doses"], security: protectedEndpointSecurity } }, + { + 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); @@ -246,7 +324,33 @@ export async function doseRoutes(app: FastifyInstance) { // --------------------------------------------------------------------------- app.post<{ Body: z.infer }>( "/doses/dismiss", - { preHandler: requireAuth, schema: { tags: ["doses"], security: protectedEndpointSecurity } }, + { + 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); @@ -299,7 +403,23 @@ export async function doseRoutes(app: FastifyInstance) { // --------------------------------------------------------------------------- app.delete( "/doses/dismiss", - { preHandler: requireAuth, schema: { tags: ["doses"], security: protectedEndpointSecurity } }, + { + 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); @@ -333,6 +453,13 @@ export async function doseRoutes(app: FastifyInstance) { app.get<{ Params: { token: string } }>( "/share/:token/doses", { + schema: { + params: tokenParamsSchema, + response: { + 200: doseReadResponseSchema, + 404: genericErrorSchema, + }, + }, logLevel: "warn", config: { rateLimit: { @@ -371,6 +498,25 @@ export async function doseRoutes(app: FastifyInstance) { // --------------------------------------------------------------------------- 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] }, + 404: genericErrorSchema, + }, + }, + }, async (request, reply) => { const { token } = request.params; @@ -427,40 +573,62 @@ export async function doseRoutes(app: FastifyInstance) { // --------------------------------------------------------------------------- // DELETE /share/:token/doses/:doseId - PUBLIC: Unmark a dose via share link // --------------------------------------------------------------------------- - app.delete<{ Params: { token: string; doseId: string } }>("/share/:token/doses/:doseId", async (request, reply) => { - const { token, doseId } = request.params; + 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 for token ${maskToken(token)} (reason=${reason})`); - return reply.notFound("Share link not found"); + const { share, reason } = await getActiveShareToken(token); + if (!share) { + request.log.warn(`[ShareDose] Rejected unmark for token ${maskToken(token)} (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 (owner=${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 (owner=${share.userId}, 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 (owner=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId})` + ); + } + + return { success: true }; } - - const isValidShareDoseId = await validateShareDoseId(share, doseId); - if (!isValidShareDoseId) { - request.log.warn( - `[ShareDose] Rejected invalid doseId in unmark request (owner=${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 (owner=${share.userId}, 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 (owner=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId})` - ); - } - - return { success: true }; - }); + ); } diff --git a/backend/src/routes/export.ts b/backend/src/routes/export.ts index e009e10..7a7b4ec 100644 --- a/backend/src/routes/export.ts +++ b/backend/src/routes/export.ts @@ -10,7 +10,11 @@ import { doseTracking, medications, refillHistory, shareTokens, userSettings } f import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; import { env } from "../plugins/env.js"; import type { AuthUser } from "../types/fastify.js"; -import { applyOpenApiRouteStandards } from "../utils/openapi-route-standards.js"; +import { + applyOpenApiRouteStandards, + genericErrorSchema, + validationErrorSchema, +} from "../utils/openapi-route-standards.js"; import { normalizePackageType, PACKAGE_TYPES } from "../utils/package-profiles.js"; import { parseIntakesJson, parseTakenByJson } from "../utils/scheduler-utils.js"; @@ -146,6 +150,69 @@ const importDataSchema = z.object({ shareLinks: z.array(shareLinkSchema).default([]), }); +const exportQuerystringSchema = { + type: "object", + properties: { + includeSensitive: { type: "string", enum: ["true", "false"] }, + includeImages: { type: "string", enum: ["true", "false"] }, + }, +} as const; + +const exportResponseSchema = { + type: "object", + properties: { + version: { type: "string" }, + exportedAt: { type: "string", format: "date-time" }, + includeSensitiveData: { type: "boolean" }, + medications: { type: "array", items: { type: "object", additionalProperties: true } }, + doseHistory: { type: "array", items: { type: "object", additionalProperties: true } }, + refillHistory: { type: "array", items: { type: "object", additionalProperties: true } }, + settings: { type: "object", additionalProperties: true }, + shareLinks: { type: "array", items: { type: "object", additionalProperties: true } }, + }, +} as const; + +const importBodyOpenApiSchema = { + type: "object", + required: ["version", "exportedAt"], + properties: { + version: { type: "string" }, + exportedAt: { type: "string", format: "date-time" }, + includeSensitiveData: { type: "boolean" }, + medications: { type: "array", items: { type: "object", additionalProperties: true } }, + doseHistory: { type: "array", items: { type: "object", additionalProperties: true } }, + refillHistory: { type: "array", items: { type: "object", additionalProperties: true } }, + settings: { type: "object", additionalProperties: true }, + shareLinks: { type: "array", items: { type: "object", additionalProperties: true } }, + }, + example: { + version: "1.8.0", + exportedAt: "2026-03-11T10:15:00.000Z", + includeSensitiveData: true, + medications: [ + { + name: "Ibuprofen 400", + packageType: "box", + packCount: 1, + looseTablets: 8, + intakes: [ + { + usage: 1, + every: 8, + start: "2026-03-11T08:00:00.000Z", + takenBy: "Daniel", + remind: true, + }, + ], + }, + ], + doseHistory: [{ doseId: "1:2026-03-11T08:00:00.000Z:Daniel", takenAt: 1773216000000 }], + refillHistory: [{ packsAdded: 1, loosePillsAdded: 4, refillDate: "2026-03-10T12:00:00.000Z" }], + settings: { language: "en", stockCalculationMode: "automatic" }, + shareLinks: [{ takenBy: "Daniel", scheduleDays: 14 }], + }, +} as const; + // ============================================================================= // Helper Functions // ============================================================================= @@ -278,239 +345,251 @@ export async function exportRoutes(app: FastifyInstance) { // --------------------------------------------------------------------------- // GET /export - Export all user data // --------------------------------------------------------------------------- - app.get<{ Querystring: { includeSensitive?: string; includeImages?: string } }>("/export", async (request, reply) => { - const userId = await getUserId(request, reply); - const includeSensitive = request.query.includeSensitive === "true"; - const includeImages = request.query.includeImages !== "false"; // Default to true - - // 1. Load all medications - const meds = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id); - - // Build medication ID to export ID mapping - const medIdToExportId = new Map(); - const exportMedications = meds.map((med, index) => { - const exportId = `med-${index + 1}`; - medIdToExportId.set(med.id, exportId); - - // Safely convert lastStockCorrectionAt to ISO string - let lastStockCorrectionAtIso: string | null = null; - if (med.lastStockCorrectionAt) { - try { - if (med.lastStockCorrectionAt instanceof Date && !Number.isNaN(med.lastStockCorrectionAt.getTime())) { - lastStockCorrectionAtIso = med.lastStockCorrectionAt.toISOString(); - } else if (typeof med.lastStockCorrectionAt === "number" || typeof med.lastStockCorrectionAt === "string") { - const d = new Date(med.lastStockCorrectionAt); - lastStockCorrectionAtIso = !Number.isNaN(d.getTime()) ? d.toISOString() : null; - } - } catch { - lastStockCorrectionAtIso = null; - } - } - - return { - _exportId: exportId, - name: med.name, - genericName: med.genericName, - takenBy: parseTakenByJson(med.takenByJson), - medicationForm: med.medicationForm ?? "tablet", - pillForm: med.pillForm ?? null, - lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty", - inventory: { - packCount: med.packCount ?? 1, - blistersPerPack: med.blistersPerPack ?? 1, - pillsPerBlister: med.pillsPerBlister ?? 1, - totalPills: med.totalPills ?? null, - looseTablets: med.looseTablets ?? 0, - stockAdjustment: med.stockAdjustment ?? 0, - packageType: normalizePackageType(med.packageType), - packageAmountValue: med.packageAmountValue ?? 0, - packageAmountUnit: (med.packageAmountUnit ?? "ml") as "ml" | "g", + app.get<{ Querystring: { includeSensitive?: string; includeImages?: string } }>( + "/export", + { + schema: { + querystring: exportQuerystringSchema, + response: { + 200: exportResponseSchema, + 401: genericErrorSchema, }, - pillWeightMg: med.pillWeightMg, - doseUnit: med.doseUnit ?? "mg", - schedules: parseIntakesForExport(med), - medicationStartDate: med.medicationStartDate || null, - medicationEndDate: med.medicationEndDate || null, - autoMarkObsoleteAfterEndDate: med.autoMarkObsoleteAfterEndDate ?? true, - expiryDate: med.expiryDate, - notes: med.notes, - intakeRemindersEnabled: med.intakeRemindersEnabled ?? false, - isObsolete: med.isObsolete ?? false, - obsoleteAt: med.obsoleteAt?.toISOString() ?? null, - prescriptionEnabled: med.prescriptionEnabled ?? false, - prescriptionAuthorizedRefills: med.prescriptionAuthorizedRefills ?? null, - prescriptionRemainingRefills: med.prescriptionRemainingRefills ?? null, - prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1, - prescriptionExpiryDate: med.prescriptionExpiryDate ?? null, - dismissedUntil: med.dismissedUntil ?? null, - image: includeImages ? imageToBase64(med.imageUrl) : null, - lastStockCorrectionAt: lastStockCorrectionAtIso, - }; - }); + }, + }, + async (request, reply) => { + const userId = await getUserId(request, reply); + const includeSensitive = request.query.includeSensitive === "true"; + const includeImages = request.query.includeImages !== "false"; // Default to true - // 2. Load all dose tracking entries - const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId)); + // 1. Load all medications + const meds = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id); - const exportDoseHistory = doses - .map((dose) => { - const parsed = parseDoseId(dose.doseId); - if (!parsed) return null; + // Build medication ID to export ID mapping + const medIdToExportId = new Map(); + const exportMedications = meds.map((med, index) => { + const exportId = `med-${index + 1}`; + medIdToExportId.set(med.id, exportId); - const exportId = medIdToExportId.get(parsed.medicationId); - if (!exportId) return null; // Orphaned dose, skip + // Safely convert lastStockCorrectionAt to ISO string + let lastStockCorrectionAtIso: string | null = null; + if (med.lastStockCorrectionAt) { + try { + if (med.lastStockCorrectionAt instanceof Date && !Number.isNaN(med.lastStockCorrectionAt.getTime())) { + lastStockCorrectionAtIso = med.lastStockCorrectionAt.toISOString(); + } else if (typeof med.lastStockCorrectionAt === "number" || typeof med.lastStockCorrectionAt === "string") { + const d = new Date(med.lastStockCorrectionAt); + lastStockCorrectionAtIso = !Number.isNaN(d.getTime()) ? d.toISOString() : null; + } + } catch { + lastStockCorrectionAtIso = null; + } + } - // Safely convert takenAt to ISO string - let takenAtIso: string; - try { - if (dose.takenAt instanceof Date && !Number.isNaN(dose.takenAt.getTime())) { - takenAtIso = dose.takenAt.toISOString(); - } else if (typeof dose.takenAt === "number" || typeof dose.takenAt === "string") { - const d = new Date(dose.takenAt); - takenAtIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString(); - } else { + return { + _exportId: exportId, + name: med.name, + genericName: med.genericName, + takenBy: parseTakenByJson(med.takenByJson), + medicationForm: med.medicationForm ?? "tablet", + pillForm: med.pillForm ?? null, + lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty", + inventory: { + packCount: med.packCount ?? 1, + blistersPerPack: med.blistersPerPack ?? 1, + pillsPerBlister: med.pillsPerBlister ?? 1, + totalPills: med.totalPills ?? null, + looseTablets: med.looseTablets ?? 0, + stockAdjustment: med.stockAdjustment ?? 0, + packageType: normalizePackageType(med.packageType), + packageAmountValue: med.packageAmountValue ?? 0, + packageAmountUnit: (med.packageAmountUnit ?? "ml") as "ml" | "g", + }, + pillWeightMg: med.pillWeightMg, + doseUnit: med.doseUnit ?? "mg", + schedules: parseIntakesForExport(med), + medicationStartDate: med.medicationStartDate || null, + medicationEndDate: med.medicationEndDate || null, + autoMarkObsoleteAfterEndDate: med.autoMarkObsoleteAfterEndDate ?? true, + expiryDate: med.expiryDate, + notes: med.notes, + intakeRemindersEnabled: med.intakeRemindersEnabled ?? false, + isObsolete: med.isObsolete ?? false, + obsoleteAt: med.obsoleteAt?.toISOString() ?? null, + prescriptionEnabled: med.prescriptionEnabled ?? false, + prescriptionAuthorizedRefills: med.prescriptionAuthorizedRefills ?? null, + prescriptionRemainingRefills: med.prescriptionRemainingRefills ?? null, + prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1, + prescriptionExpiryDate: med.prescriptionExpiryDate ?? null, + dismissedUntil: med.dismissedUntil ?? null, + image: includeImages ? imageToBase64(med.imageUrl) : null, + lastStockCorrectionAt: lastStockCorrectionAtIso, + }; + }); + + // 2. Load all dose tracking entries + const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId)); + + const exportDoseHistory = doses + .map((dose) => { + const parsed = parseDoseId(dose.doseId); + if (!parsed) return null; + + const exportId = medIdToExportId.get(parsed.medicationId); + if (!exportId) return null; // Orphaned dose, skip + + // Safely convert takenAt to ISO string + let takenAtIso: string; + try { + if (dose.takenAt instanceof Date && !Number.isNaN(dose.takenAt.getTime())) { + takenAtIso = dose.takenAt.toISOString(); + } else if (typeof dose.takenAt === "number" || typeof dose.takenAt === "string") { + const d = new Date(dose.takenAt); + takenAtIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString(); + } else { + takenAtIso = new Date().toISOString(); + } + } catch { takenAtIso = new Date().toISOString(); } - } catch { - takenAtIso = new Date().toISOString(); - } - // Safely convert scheduled time - let scheduledTimeIso: string; - try { - const d = new Date(parsed.timestampMs); - scheduledTimeIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString(); - } catch { - scheduledTimeIso = new Date().toISOString(); + // Safely convert scheduled time + let scheduledTimeIso: string; + try { + const d = new Date(parsed.timestampMs); + scheduledTimeIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString(); + } catch { + scheduledTimeIso = new Date().toISOString(); + } + + return { + medicationRef: exportId, + scheduleIndex: parsed.blisterIndex, + scheduledTime: scheduledTimeIso, + takenAt: takenAtIso, + markedBy: dose.markedBy, + takenSource: dose.takenSource === "automatic" ? "automatic" : "manual", + dismissed: dose.dismissed ?? false, + takenByPerson: parsed.person, + }; + }) + .filter((d): d is NonNullable => d !== null); + + // 3. Load user settings + const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId)); + + const exportSettings = settings + ? { + emailEnabled: settings.emailEnabled, + notificationEmail: settings.notificationEmail, + emailStockReminders: settings.emailStockReminders, + emailIntakeReminders: settings.emailIntakeReminders, + emailPrescriptionReminders: settings.emailPrescriptionReminders ?? true, + // Only include sensitive data if requested + shoutrrrEnabled: includeSensitive ? settings.shoutrrrEnabled : undefined, + shoutrrrUrl: includeSensitive ? settings.shoutrrrUrl : undefined, + shoutrrrStockReminders: settings.shoutrrrStockReminders, + shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders, + shoutrrrPrescriptionReminders: settings.shoutrrrPrescriptionReminders ?? true, + reminderDaysBefore: settings.reminderDaysBefore, + repeatDailyReminders: settings.repeatDailyReminders, + skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses, + repeatRemindersEnabled: settings.repeatRemindersEnabled, + reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes, + maxNaggingReminders: settings.maxNaggingReminders, + lowStockDays: settings.lowStockDays, + normalStockDays: settings.normalStockDays, + highStockDays: settings.highStockDays, + expiryWarningDays: settings.expiryWarningDays, + language: settings.language, + stockCalculationMode: settings.stockCalculationMode, + shareStockStatus: settings.shareStockStatus, + } + : undefined; + + // 4. Load share links + const shares = await db.select().from(shareTokens).where(eq(shareTokens.userId, userId)); + + const exportShareLinks = shares.map((share) => { + // Safely convert expiresAt to ISO string + let expiresAtIso: string | null = null; + if (share.expiresAt) { + try { + if (share.expiresAt instanceof Date && !Number.isNaN(share.expiresAt.getTime())) { + expiresAtIso = share.expiresAt.toISOString(); + } else if (typeof share.expiresAt === "number" || typeof share.expiresAt === "string") { + const d = new Date(share.expiresAt); + expiresAtIso = !Number.isNaN(d.getTime()) ? d.toISOString() : null; + } + } catch { + expiresAtIso = null; + } } return { - medicationRef: exportId, - scheduleIndex: parsed.blisterIndex, - scheduledTime: scheduledTimeIso, - takenAt: takenAtIso, - markedBy: dose.markedBy, - takenSource: dose.takenSource === "automatic" ? "automatic" : "manual", - dismissed: dose.dismissed ?? false, - takenByPerson: parsed.person, + takenBy: share.takenBy, + scheduleDays: share.scheduleDays, + expiresAt: expiresAtIso, + regenerateToken: true, // Always regenerate tokens on import for security }; - }) - .filter((d): d is NonNullable => d !== null); + }); - // 3. Load user settings - const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId)); + // 5. Load refill history + const refills = await db.select().from(refillHistory).where(eq(refillHistory.userId, userId)); - const exportSettings = settings - ? { - emailEnabled: settings.emailEnabled, - notificationEmail: settings.notificationEmail, - emailStockReminders: settings.emailStockReminders, - emailIntakeReminders: settings.emailIntakeReminders, - emailPrescriptionReminders: settings.emailPrescriptionReminders ?? true, - // Only include sensitive data if requested - shoutrrrEnabled: includeSensitive ? settings.shoutrrrEnabled : undefined, - shoutrrrUrl: includeSensitive ? settings.shoutrrrUrl : undefined, - shoutrrrStockReminders: settings.shoutrrrStockReminders, - shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders, - shoutrrrPrescriptionReminders: settings.shoutrrrPrescriptionReminders ?? true, - reminderDaysBefore: settings.reminderDaysBefore, - repeatDailyReminders: settings.repeatDailyReminders, - skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses, - repeatRemindersEnabled: settings.repeatRemindersEnabled, - reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes, - maxNaggingReminders: settings.maxNaggingReminders, - lowStockDays: settings.lowStockDays, - normalStockDays: settings.normalStockDays, - highStockDays: settings.highStockDays, - expiryWarningDays: settings.expiryWarningDays, - language: settings.language, - stockCalculationMode: settings.stockCalculationMode, - shareStockStatus: settings.shareStockStatus, - } - : undefined; + const exportRefillHistory = refills + .map((refill) => { + const exportId = medIdToExportId.get(refill.medicationId); + if (!exportId) return null; // Orphaned refill, skip - // 4. Load share links - const shares = await db.select().from(shareTokens).where(eq(shareTokens.userId, userId)); - - const exportShareLinks = shares.map((share) => { - // Safely convert expiresAt to ISO string - let expiresAtIso: string | null = null; - if (share.expiresAt) { - try { - if (share.expiresAt instanceof Date && !Number.isNaN(share.expiresAt.getTime())) { - expiresAtIso = share.expiresAt.toISOString(); - } else if (typeof share.expiresAt === "number" || typeof share.expiresAt === "string") { - const d = new Date(share.expiresAt); - expiresAtIso = !Number.isNaN(d.getTime()) ? d.toISOString() : null; - } - } catch { - expiresAtIso = null; - } - } - - return { - takenBy: share.takenBy, - scheduleDays: share.scheduleDays, - expiresAt: expiresAtIso, - regenerateToken: true, // Always regenerate tokens on import for security - }; - }); - - // 5. Load refill history - const refills = await db.select().from(refillHistory).where(eq(refillHistory.userId, userId)); - - const exportRefillHistory = refills - .map((refill) => { - const exportId = medIdToExportId.get(refill.medicationId); - if (!exportId) return null; // Orphaned refill, skip - - // Safely convert refillDate to ISO string - let refillDateIso: string; - try { - if (refill.refillDate instanceof Date && !Number.isNaN(refill.refillDate.getTime())) { - refillDateIso = refill.refillDate.toISOString(); - } else if (typeof refill.refillDate === "number" || typeof refill.refillDate === "string") { - const d = new Date(refill.refillDate); - refillDateIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString(); - } else { + // Safely convert refillDate to ISO string + let refillDateIso: string; + try { + if (refill.refillDate instanceof Date && !Number.isNaN(refill.refillDate.getTime())) { + refillDateIso = refill.refillDate.toISOString(); + } else if (typeof refill.refillDate === "number" || typeof refill.refillDate === "string") { + const d = new Date(refill.refillDate); + refillDateIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString(); + } else { + refillDateIso = new Date().toISOString(); + } + } catch { refillDateIso = new Date().toISOString(); } - } catch { - refillDateIso = new Date().toISOString(); - } - return { - medicationRef: exportId, - packsAdded: refill.packsAdded ?? 0, - loosePillsAdded: refill.loosePillsAdded ?? 0, - usedPrescription: refill.usedPrescription ?? false, - refillDate: refillDateIso, - }; - }) - .filter((r): r is NonNullable => r !== null); + return { + medicationRef: exportId, + packsAdded: refill.packsAdded ?? 0, + loosePillsAdded: refill.loosePillsAdded ?? 0, + usedPrescription: refill.usedPrescription ?? false, + refillDate: refillDateIso, + }; + }) + .filter((r): r is NonNullable => r !== null); - // Build export object - const exportData = { - version: EXPORT_VERSION, - exportedAt: new Date().toISOString(), - includeSensitiveData: includeSensitive, - medications: exportMedications, - doseHistory: exportDoseHistory, - refillHistory: exportRefillHistory, - settings: exportSettings, - shareLinks: exportShareLinks, - }; + // Build export object + const exportData = { + version: EXPORT_VERSION, + exportedAt: new Date().toISOString(), + includeSensitiveData: includeSensitive, + medications: exportMedications, + doseHistory: exportDoseHistory, + refillHistory: exportRefillHistory, + settings: exportSettings, + shareLinks: exportShareLinks, + }; - // Set download headers - const now = new Date(); - const dateStr = now.toISOString().replace(/[-:]/g, "").replace(/T/, "-").slice(0, 13); - const authUser = env.AUTH_ENABLED ? (request.user as unknown as AuthUser | null) : null; - const userPart = authUser?.username ? `-${authUser.username}` : ""; - const filename = `medassist-export${userPart}-${dateStr}.json`; - reply.header("Content-Type", "application/json"); - reply.header("Content-Disposition", `attachment; filename="${filename}"`); + // Set download headers + const now = new Date(); + const dateStr = now.toISOString().replace(/[-:]/g, "").replace(/T/, "-").slice(0, 13); + const authUser = env.AUTH_ENABLED ? (request.user as unknown as AuthUser | null) : null; + const userPart = authUser?.username ? `-${authUser.username}` : ""; + const filename = `medassist-export${userPart}-${dateStr}.json`; + reply.header("Content-Type", "application/json"); + reply.header("Content-Disposition", `attachment; filename="${filename}"`); - return exportData; - }); + return exportData; + } + ); // --------------------------------------------------------------------------- // POST /import - Import user data (replaces all existing data!) @@ -523,6 +602,29 @@ export async function exportRoutes(app: FastifyInstance) { rawBody: true, }, bodyLimit: 50 * 1024 * 1024, // 50 MB + schema: { + body: importBodyOpenApiSchema, + response: { + 200: { + type: "object", + properties: { + success: { type: "boolean" }, + imported: { + type: "object", + properties: { + medications: { type: "integer" }, + doseHistory: { type: "integer" }, + refillHistory: { type: "integer" }, + settings: { type: "integer" }, + shareLinks: { type: "integer" }, + }, + }, + }, + }, + 400: { anyOf: [genericErrorSchema, validationErrorSchema] }, + 401: genericErrorSchema, + }, + }, }, async (request, reply) => { const userId = await getUserId(request, reply); diff --git a/backend/src/routes/health.ts b/backend/src/routes/health.ts index b3118b7..d97d649 100644 --- a/backend/src/routes/health.ts +++ b/backend/src/routes/health.ts @@ -14,9 +14,28 @@ export async function healthRoutes(app: FastifyInstance) { applyOpenApiRouteStandards(app, { tag: "health", protectedByDefault: false }); // Exempt from rate limit + suppress request logs (called every 30s by Docker healthcheck) - app.get("/health", { config: { rateLimit: false }, logLevel: "warn" }, async () => ({ - status: "ok", - version: backendVersion, - smtpConfigured: Boolean(process.env.SMTP_HOST), - })); + app.get( + "/health", + { + config: { rateLimit: false }, + logLevel: "warn", + schema: { + response: { + 200: { + type: "object", + properties: { + status: { type: "string", enum: ["ok"] }, + version: { type: "string" }, + smtpConfigured: { type: "boolean" }, + }, + }, + }, + }, + }, + async () => ({ + status: "ok", + version: backendVersion, + smtpConfigured: Boolean(process.env.SMTP_HOST), + }) + ); } diff --git a/backend/src/routes/medications.ts b/backend/src/routes/medications.ts index ab032b5..d62bd55 100644 --- a/backend/src/routes/medications.ts +++ b/backend/src/routes/medications.ts @@ -14,7 +14,13 @@ import { streamToBuffer, writeOptimizedImageSet, } from "../utils/image-upload.js"; -import { applyOpenApiRouteStandards } from "../utils/openapi-route-standards.js"; +import { + applyOpenApiRouteStandards, + genericErrorSchema, + idParamsSchema, + successResponseSchema, + validationErrorSchema, +} from "../utils/openapi-route-standards.js"; import { isAmountBasedPackageType, isLiquidContainerPackageType, @@ -66,6 +72,29 @@ function parseIntakesWithUnits( })); } +function normalizeDateTime(value: unknown): string | null { + if (value == null) { + return null; + } + + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? null : value.toISOString(); + } + + if (typeof value === "number") { + const timestampMs = value < 1_000_000_000_000 ? value * 1000 : value; + const date = new Date(timestampMs); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); + } + + if (typeof value === "string") { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); + } + + return null; +} + // New intake schema with per-intake takenBy const intakeSchema = z.object({ usage: z.number().nonnegative(), @@ -238,6 +267,231 @@ const medicationSchema = z } ); +const intakeOpenApiSchema = { + type: "object", + required: ["usage", "every", "start"], + properties: { + usage: { type: "number", minimum: 0 }, + every: { type: "integer", minimum: 1 }, + start: { type: "string", description: "ISO datetime string; timezone suffix optional." }, + intakeUnit: { type: ["string", "null"], enum: ["ml", "tsp", "tbsp", null] }, + takenBy: { type: ["string", "null"], maxLength: 100 }, + intakeRemindersEnabled: { type: "boolean" }, + }, +} as const; + +const blisterOpenApiSchema = { + type: "object", + required: ["usage", "every", "start"], + properties: { + usage: { type: "number", minimum: 0 }, + every: { type: "integer", minimum: 1 }, + start: { type: "string", description: "ISO datetime string; timezone suffix optional." }, + }, +} as const; + +const medicationBodyOpenApiSchema = { + type: "object", + properties: { + name: { type: "string", maxLength: 100 }, + genericName: { type: ["string", "null"], maxLength: 100 }, + takenBy: { type: "array", items: { type: "string", maxLength: 100 } }, + medicationForm: { type: "string", enum: ["capsule", "tablet", "liquid", "topical"] }, + pillForm: { type: ["string", "null"], enum: ["capsule", "tablet", null] }, + lifecycleCategory: { type: "string", enum: ["refill_when_empty", "treatment_period"] }, + packageType: { type: "string", enum: PACKAGE_TYPES }, + packCount: { type: "integer", minimum: 0 }, + blistersPerPack: { type: "integer", minimum: 1 }, + pillsPerBlister: { type: "integer", minimum: 1 }, + packageAmountValue: { type: "integer", minimum: 0 }, + packageAmountUnit: { type: "string", enum: ["ml", "g"] }, + totalPills: { type: ["integer", "null"], minimum: 1 }, + looseTablets: { type: "integer", minimum: 0 }, + pillWeightMg: { type: ["number", "null"], minimum: 0 }, + doseUnit: { type: "string", enum: ["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"] }, + medicationStartDate: { + anyOf: [{ type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$" }, { type: "null" }, { const: "" }], + }, + medicationEndDate: { + anyOf: [{ type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$" }, { type: "null" }, { const: "" }], + }, + autoMarkObsoleteAfterEndDate: { type: "boolean" }, + expiryDate: { type: ["string", "null"] }, + notes: { type: ["string", "null"], maxLength: 2000 }, + prescriptionEnabled: { type: "boolean" }, + prescriptionAuthorizedRefills: { type: ["integer", "null"], minimum: 0 }, + prescriptionRemainingRefills: { type: ["integer", "null"], minimum: 0 }, + prescriptionLowRefillThreshold: { type: "integer", minimum: 0 }, + prescriptionExpiryDate: { type: ["string", "null"] }, + intakeRemindersEnabled: { type: "boolean" }, + intakes: { type: "array", items: intakeOpenApiSchema }, + blisters: { type: "array", items: blisterOpenApiSchema }, + }, + description: + "Medication payload. Runtime validation allows defaults and legacy shapes; provide either intakes or legacy blisters.", + example: { + name: "Ibuprofen 400", + genericName: "Ibuprofen", + takenBy: ["Daniel"], + medicationForm: "tablet", + pillForm: "tablet", + lifecycleCategory: "refill_when_empty", + packageType: "box", + packCount: 1, + blistersPerPack: 2, + pillsPerBlister: 10, + totalPills: 20, + looseTablets: 8, + pillWeightMg: 400, + doseUnit: "mg", + medicationStartDate: "2026-03-01", + autoMarkObsoleteAfterEndDate: false, + expiryDate: "2027-12-31", + notes: "Take after meals.", + prescriptionEnabled: true, + prescriptionAuthorizedRefills: 3, + prescriptionRemainingRefills: 2, + prescriptionLowRefillThreshold: 1, + prescriptionExpiryDate: "2026-12-31", + intakeRemindersEnabled: true, + intakes: [ + { + usage: 1, + every: 8, + start: "2026-03-11T08:00:00.000Z", + takenBy: "Daniel", + intakeRemindersEnabled: true, + }, + ], + }, +} as const; + +const medicationResponseSchema = { + type: "object", + properties: { + id: { type: "number" }, + name: { type: "string" }, + genericName: { type: ["string", "null"] }, + takenBy: { type: "array", items: { type: "string" } }, + medicationForm: { type: "string" }, + pillForm: { type: ["string", "null"] }, + lifecycleCategory: { type: "string" }, + packageType: { type: "string" }, + packCount: { type: "integer" }, + blistersPerPack: { type: "integer" }, + pillsPerBlister: { type: "integer" }, + packageAmountValue: { type: "integer" }, + packageAmountUnit: { type: "string" }, + totalPills: { type: ["number", "null"] }, + looseTablets: { type: "number" }, + stockAdjustment: { type: "number" }, + lastStockCorrectionAt: { type: ["string", "null"] }, + pillWeightMg: { type: ["number", "null"] }, + doseUnit: { type: "string" }, + medicationStartDate: { type: ["string", "null"] }, + medicationEndDate: { type: ["string", "null"] }, + autoMarkObsoleteAfterEndDate: { type: "boolean" }, + intakes: { type: "array", items: intakeOpenApiSchema }, + blisters: { type: "array", items: blisterOpenApiSchema }, + imageUrl: { type: ["string", "null"] }, + expiryDate: { type: ["string", "null"] }, + notes: { type: ["string", "null"] }, + intakeRemindersEnabled: { type: "boolean" }, + isObsolete: { type: "boolean" }, + obsoleteAt: { type: ["string", "null"] }, + prescriptionEnabled: { type: "boolean" }, + prescriptionAuthorizedRefills: { type: ["integer", "null"] }, + prescriptionRemainingRefills: { type: ["integer", "null"] }, + prescriptionLowRefillThreshold: { type: "integer" }, + prescriptionExpiryDate: { type: ["string", "null"] }, + dismissedUntil: { type: ["string", "null"] }, + updatedAt: { type: ["string", "null"], format: "date-time" }, + }, +} as const; + +const usageRequestSchema = { + type: "object", + required: ["startDate", "endDate"], + properties: { + startDate: { type: "string", format: "date-time" }, + endDate: { type: "string", format: "date-time" }, + includeUntilStart: { type: "boolean", default: false }, + }, + example: { + startDate: "2026-03-01T00:00:00.000Z", + endDate: "2026-03-31T23:59:59.000Z", + includeUntilStart: false, + }, +} as const; + +const usageItemSchema = { + type: "object", + properties: { + medicationId: { type: "number" }, + medicationName: { type: "string" }, + totalPills: { type: "number" }, + currentPills: { type: "number" }, + plannerUsage: { type: "number" }, + blisterSize: { type: "number" }, + blistersNeeded: { type: "number" }, + fullBlisters: { type: "number" }, + loosePills: { type: "number" }, + enough: { type: "boolean" }, + packageType: { type: "string" }, + }, +} as const; + +const stockAdjustmentBodySchema = { + type: "object", + required: ["stockAdjustment"], + properties: { + stockAdjustment: { type: "number" }, + looseTablets: { type: "integer", minimum: 0 }, + totalPills: { type: "integer", minimum: 0 }, + packageAmountValue: { type: "integer", minimum: 0 }, + packCount: { type: "integer", minimum: 1 }, + }, + example: { + stockAdjustment: -2, + looseTablets: 6, + totalPills: 16, + packCount: 1, + }, +} as const; + +const stockAdjustmentResponseSchema = { + type: "object", + properties: { + id: { type: "number" }, + stockAdjustment: { type: "number" }, + lastStockCorrectionAt: { type: "string" }, + updatedAt: { type: "string", format: "date-time" }, + }, +} as const; + +const obsoleteStateResponseSchema = { + type: "object", + properties: { + id: { type: "number" }, + isObsolete: { type: "boolean" }, + obsoleteAt: { type: "string" }, + updatedAt: { type: "string", format: "date-time" }, + }, +} as const; + +const dismissUntilBodySchema = { + type: "object", + required: ["medicationIds", "until"], + properties: { + medicationIds: { type: "array", minItems: 1, items: { type: "integer", minimum: 1 } }, + until: { type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$" }, + }, + example: { + medicationIds: [1, 2], + until: "2026-03-20", + }, +} as const; + export async function medicationRoutes(app: FastifyInstance) { // All medication routes require auth app.addHook("preHandler", requireAuth); @@ -260,587 +514,660 @@ export async function medicationRoutes(app: FastifyInstance) { return authUser.id; } - app.get<{ Querystring: { includeObsolete?: string } }>("/medications", async (request, reply) => { - const userId = await getUserId(request, reply); - const includeObsolete = request.query.includeObsolete === "true"; - const initialRows = await db - .select() - .from(medications) - .where(eq(medications.userId, userId)) - .orderBy(medications.id); - const todayDate = new Date().toISOString().slice(0, 10); + app.get<{ Querystring: { includeObsolete?: string } }>( + "/medications", + { + schema: { + querystring: { + type: "object", + properties: { + includeObsolete: { type: "string", enum: ["true", "false"] }, + }, + }, + response: { + 200: { type: "array", items: medicationResponseSchema }, + 401: genericErrorSchema, + }, + }, + }, + async (request, reply) => { + const userId = await getUserId(request, reply); + const includeObsolete = request.query.includeObsolete === "true"; + const initialRows = await db + .select() + .from(medications) + .where(eq(medications.userId, userId)) + .orderBy(medications.id); + const todayDate = new Date().toISOString().slice(0, 10); - for (const row of initialRows) { - if (row.isObsolete) continue; - if (!(row.autoMarkObsoleteAfterEndDate ?? true)) continue; - const endDate = row.medicationEndDate?.slice(0, 10); - if (!endDate) continue; - if (endDate > todayDate) continue; + for (const row of initialRows) { + if (row.isObsolete) continue; + if (!(row.autoMarkObsoleteAfterEndDate ?? true)) continue; + const endDate = row.medicationEndDate?.slice(0, 10); + if (!endDate) continue; + if (endDate > todayDate) continue; - await db - .update(medications) - .set({ isObsolete: true, obsoleteAt: new Date(), updatedAt: new Date() }) - .where(and(eq(medications.id, row.id), eq(medications.userId, userId))); + await db + .update(medications) + .set({ isObsolete: true, obsoleteAt: new Date(), updatedAt: new Date() }) + .where(and(eq(medications.id, row.id), eq(medications.userId, userId))); + } + + const whereClause = includeObsolete + ? eq(medications.userId, userId) + : and(eq(medications.userId, userId), eq(medications.isObsolete, false)); + const rows = await db.select().from(medications).where(whereClause).orderBy(medications.id); + return rows.map((row) => { + // Parse intakes from new format, falling back to legacy + const intakes = parseIntakesWithUnits( + row.intakesJson, + { usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson }, + row.intakeRemindersEnabled ?? false + ); + + return { + id: row.id, + name: row.name, + genericName: row.genericName, + takenBy: parseTakenByJson(row.takenByJson), + medicationForm: row.medicationForm ?? "tablet", + pillForm: row.pillForm ?? null, + lifecycleCategory: row.lifecycleCategory ?? "refill_when_empty", + packageType: normalizePackageType(row.packageType), + packCount: row.packCount ?? 1, + blistersPerPack: row.blistersPerPack ?? 1, + pillsPerBlister: row.pillsPerBlister ?? 1, + packageAmountValue: row.packageAmountValue ?? 0, + packageAmountUnit: (row.packageAmountUnit ?? "ml") as "ml" | "g", + totalPills: row.totalPills ?? null, + looseTablets: row.looseTablets ?? 0, + stockAdjustment: row.stockAdjustment ?? 0, + lastStockCorrectionAt: row.lastStockCorrectionAt?.toISOString() ?? null, + pillWeightMg: row.pillWeightMg, + doseUnit: row.doseUnit ?? "mg", + medicationStartDate: row.medicationStartDate || null, + medicationEndDate: row.medicationEndDate || null, + autoMarkObsoleteAfterEndDate: row.autoMarkObsoleteAfterEndDate ?? true, + intakes, // New unified format with per-intake takenBy + // Legacy blisters format (for backward compat with frontend during transition) + blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })), + imageUrl: row.imageUrl, + expiryDate: row.expiryDate, + notes: row.notes, + intakeRemindersEnabled: row.intakeRemindersEnabled ?? false, + isObsolete: row.isObsolete ?? false, + obsoleteAt: row.obsoleteAt?.toISOString() ?? null, + prescriptionEnabled: row.prescriptionEnabled ?? false, + prescriptionAuthorizedRefills: row.prescriptionAuthorizedRefills ?? null, + prescriptionRemainingRefills: row.prescriptionRemainingRefills ?? null, + prescriptionLowRefillThreshold: row.prescriptionLowRefillThreshold ?? 1, + prescriptionExpiryDate: row.prescriptionExpiryDate ?? null, + dismissedUntil: row.dismissedUntil ?? null, + updatedAt: normalizeDateTime(row.updatedAt), + }; + }); } + ); - const whereClause = includeObsolete - ? eq(medications.userId, userId) - : and(eq(medications.userId, userId), eq(medications.isObsolete, false)); - const rows = await db.select().from(medications).where(whereClause).orderBy(medications.id); - return rows.map((row) => { - // Parse intakes from new format, falling back to legacy - const intakes = parseIntakesWithUnits( - row.intakesJson, - { usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson }, - row.intakeRemindersEnabled ?? false - ); + app.post( + "/medications", + { + schema: { + body: medicationBodyOpenApiSchema, + response: { + 200: medicationResponseSchema, + 400: { anyOf: [genericErrorSchema, validationErrorSchema] }, + 401: genericErrorSchema, + }, + }, + }, + async (req, reply) => { + const parsed = medicationSchema.safeParse(req.body); + if (!parsed.success) return reply.status(400).send(parsed.error.format()); + + const userId = await getUserId(req, reply); + const { + name, + genericName, + takenBy, + medicationForm, + pillForm, + lifecycleCategory, + packageType, + packCount, + blistersPerPack, + pillsPerBlister, + packageAmountValue, + packageAmountUnit, + totalPills, + looseTablets, + pillWeightMg, + doseUnit, + medicationStartDate, + medicationEndDate, + autoMarkObsoleteAfterEndDate, + expiryDate, + notes, + prescriptionEnabled, + prescriptionAuthorizedRefills, + prescriptionRemainingRefills, + prescriptionLowRefillThreshold, + prescriptionExpiryDate, + intakeRemindersEnabled, + intakes: inputIntakes, + blisters: inputBlisters, + } = parsed.data; + + const normalizedPillForm = + medicationForm === "capsule" || medicationForm === "tablet" ? (pillForm ?? medicationForm) : null; + + // Convert to unified intakes format + let intakes: Intake[]; + if (inputIntakes) { + // New format with per-intake takenBy + intakes = inputIntakes.map((i) => ({ + usage: i.usage, + every: i.every, + start: i.start, + intakeUnit: i.intakeUnit ?? null, + takenBy: i.takenBy || null, + intakeRemindersEnabled: i.intakeRemindersEnabled ?? false, + })); + } else if (inputBlisters) { + // Legacy format - convert to new format + intakes = inputBlisters.map((b) => ({ + usage: b.usage, + every: b.every, + start: b.start, + intakeUnit: null, + takenBy: null, // No per-intake takenBy from legacy + intakeRemindersEnabled: intakeRemindersEnabled ?? false, + })); + } else { + return reply.status(400).send({ error: "Either 'intakes' or 'blisters' must be provided" }); + } + + // Store both formats for backward compatibility + const intakesJson = JSON.stringify(intakes); + const usageJson = JSON.stringify(intakes.map((s) => s.usage)); + const everyJson = JSON.stringify(intakes.map((s) => s.every)); + const startJson = JSON.stringify(intakes.map((s) => s.start)); + const takenByJson = JSON.stringify(takenBy || []); + + const [inserted] = await db + .insert(medications) + .values({ + userId, + name, + genericName: genericName || null, + takenByJson, + medicationForm: medicationForm ?? "tablet", + pillForm: normalizedPillForm, + lifecycleCategory: lifecycleCategory ?? "refill_when_empty", + packageType: normalizePackageType(packageType), + packCount, + blistersPerPack, + pillsPerBlister, + packageAmountValue, + packageAmountUnit, + totalPills: totalPills || null, + looseTablets, + pillWeightMg: pillWeightMg || null, + doseUnit: doseUnit ?? "mg", + medicationStartDate: medicationStartDate ?? "", + medicationEndDate: medicationEndDate || null, + autoMarkObsoleteAfterEndDate: autoMarkObsoleteAfterEndDate ?? true, + expiryDate: expiryDate || null, + notes: notes || null, + prescriptionEnabled: prescriptionEnabled ?? false, + prescriptionAuthorizedRefills: prescriptionEnabled ? (prescriptionAuthorizedRefills ?? null) : null, + prescriptionRemainingRefills: prescriptionEnabled ? (prescriptionRemainingRefills ?? null) : null, + prescriptionLowRefillThreshold: prescriptionLowRefillThreshold ?? 1, + prescriptionExpiryDate: prescriptionExpiryDate || null, + intakeRemindersEnabled: intakeRemindersEnabled ?? false, + intakesJson, + usageJson, + everyJson, + startJson, + }) + .returning(); return { - id: row.id, - name: row.name, - genericName: row.genericName, - takenBy: parseTakenByJson(row.takenByJson), - medicationForm: row.medicationForm ?? "tablet", - pillForm: row.pillForm ?? null, - lifecycleCategory: row.lifecycleCategory ?? "refill_when_empty", - packageType: normalizePackageType(row.packageType), - packCount: row.packCount ?? 1, - blistersPerPack: row.blistersPerPack ?? 1, - pillsPerBlister: row.pillsPerBlister ?? 1, - packageAmountValue: row.packageAmountValue ?? 0, - packageAmountUnit: (row.packageAmountUnit ?? "ml") as "ml" | "g", - totalPills: row.totalPills ?? null, - looseTablets: row.looseTablets ?? 0, - stockAdjustment: row.stockAdjustment ?? 0, - lastStockCorrectionAt: row.lastStockCorrectionAt?.toISOString() ?? null, - pillWeightMg: row.pillWeightMg, - doseUnit: row.doseUnit ?? "mg", - medicationStartDate: row.medicationStartDate || null, - medicationEndDate: row.medicationEndDate || null, - autoMarkObsoleteAfterEndDate: row.autoMarkObsoleteAfterEndDate ?? true, - intakes, // New unified format with per-intake takenBy - // Legacy blisters format (for backward compat with frontend during transition) + id: inserted.id, + name: inserted.name, + genericName: inserted.genericName, + takenBy: parseTakenByJson(inserted.takenByJson), + medicationForm: inserted.medicationForm ?? "tablet", + pillForm: inserted.pillForm ?? null, + lifecycleCategory: inserted.lifecycleCategory ?? "refill_when_empty", + packageType: normalizePackageType(inserted.packageType), + packCount: inserted.packCount, + blistersPerPack: inserted.blistersPerPack, + pillsPerBlister: inserted.pillsPerBlister, + packageAmountValue: inserted.packageAmountValue ?? 0, + packageAmountUnit: (inserted.packageAmountUnit ?? "ml") as "ml" | "g", + totalPills: inserted.totalPills ?? null, + looseTablets: inserted.looseTablets, + stockAdjustment: inserted.stockAdjustment ?? 0, + lastStockCorrectionAt: inserted.lastStockCorrectionAt?.toISOString() ?? null, + pillWeightMg: inserted.pillWeightMg, + doseUnit: inserted.doseUnit ?? "mg", + medicationStartDate: inserted.medicationStartDate || null, + medicationEndDate: inserted.medicationEndDate || null, + autoMarkObsoleteAfterEndDate: inserted.autoMarkObsoleteAfterEndDate ?? true, + intakes, blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })), - imageUrl: row.imageUrl, - expiryDate: row.expiryDate, - notes: row.notes, - intakeRemindersEnabled: row.intakeRemindersEnabled ?? false, - isObsolete: row.isObsolete ?? false, - obsoleteAt: row.obsoleteAt?.toISOString() ?? null, - prescriptionEnabled: row.prescriptionEnabled ?? false, - prescriptionAuthorizedRefills: row.prescriptionAuthorizedRefills ?? null, - prescriptionRemainingRefills: row.prescriptionRemainingRefills ?? null, - prescriptionLowRefillThreshold: row.prescriptionLowRefillThreshold ?? 1, - prescriptionExpiryDate: row.prescriptionExpiryDate ?? null, - dismissedUntil: row.dismissedUntil ?? null, - updatedAt: row.updatedAt, + imageUrl: inserted.imageUrl, + expiryDate: inserted.expiryDate, + notes: inserted.notes, + intakeRemindersEnabled: inserted.intakeRemindersEnabled, + isObsolete: inserted.isObsolete ?? false, + obsoleteAt: inserted.obsoleteAt?.toISOString() ?? null, + prescriptionEnabled: inserted.prescriptionEnabled ?? false, + prescriptionAuthorizedRefills: inserted.prescriptionAuthorizedRefills ?? null, + prescriptionRemainingRefills: inserted.prescriptionRemainingRefills ?? null, + prescriptionLowRefillThreshold: inserted.prescriptionLowRefillThreshold ?? 1, + prescriptionExpiryDate: inserted.prescriptionExpiryDate ?? null, + updatedAt: normalizeDateTime(inserted.updatedAt), }; - }); - }); - - app.post("/medications", async (req, reply) => { - const parsed = medicationSchema.safeParse(req.body); - if (!parsed.success) return reply.status(400).send(parsed.error.format()); - - const userId = await getUserId(req, reply); - const { - name, - genericName, - takenBy, - medicationForm, - pillForm, - lifecycleCategory, - packageType, - packCount, - blistersPerPack, - pillsPerBlister, - packageAmountValue, - packageAmountUnit, - totalPills, - looseTablets, - pillWeightMg, - doseUnit, - medicationStartDate, - medicationEndDate, - autoMarkObsoleteAfterEndDate, - expiryDate, - notes, - prescriptionEnabled, - prescriptionAuthorizedRefills, - prescriptionRemainingRefills, - prescriptionLowRefillThreshold, - prescriptionExpiryDate, - intakeRemindersEnabled, - intakes: inputIntakes, - blisters: inputBlisters, - } = parsed.data; - - const normalizedPillForm = - medicationForm === "capsule" || medicationForm === "tablet" ? (pillForm ?? medicationForm) : null; - - // Convert to unified intakes format - let intakes: Intake[]; - if (inputIntakes) { - // New format with per-intake takenBy - intakes = inputIntakes.map((i) => ({ - usage: i.usage, - every: i.every, - start: i.start, - intakeUnit: i.intakeUnit ?? null, - takenBy: i.takenBy || null, - intakeRemindersEnabled: i.intakeRemindersEnabled ?? false, - })); - } else if (inputBlisters) { - // Legacy format - convert to new format - intakes = inputBlisters.map((b) => ({ - usage: b.usage, - every: b.every, - start: b.start, - intakeUnit: null, - takenBy: null, // No per-intake takenBy from legacy - intakeRemindersEnabled: intakeRemindersEnabled ?? false, - })); - } else { - return reply.status(400).send({ error: "Either 'intakes' or 'blisters' must be provided" }); } + ); - // Store both formats for backward compatibility - const intakesJson = JSON.stringify(intakes); - const usageJson = JSON.stringify(intakes.map((s) => s.usage)); - const everyJson = JSON.stringify(intakes.map((s) => s.every)); - const startJson = JSON.stringify(intakes.map((s) => s.start)); - const takenByJson = JSON.stringify(takenBy || []); + app.put<{ Params: { id: string } }>( + "/medications/:id", + { + schema: { + params: idParamsSchema, + body: medicationBodyOpenApiSchema, + response: { + 200: medicationResponseSchema, + 400: { anyOf: [genericErrorSchema, validationErrorSchema] }, + 401: genericErrorSchema, + 404: genericErrorSchema, + }, + }, + }, + async (req, reply) => { + const parsed = medicationSchema.safeParse(req.body); + if (!parsed.success) return reply.status(400).send(parsed.error.format()); + const idNum = Number(req.params.id); + if (Number.isNaN(idNum)) return reply.badRequest("Invalid id"); - const [inserted] = await db - .insert(medications) - .values({ - userId, + const userId = await getUserId(req, reply); + + // Verify ownership + const [existing] = await db + .select() + .from(medications) + .where(and(eq(medications.id, idNum), eq(medications.userId, userId))); + if (!existing) return reply.notFound(); + + const { name, - genericName: genericName || null, - takenByJson, - medicationForm: medicationForm ?? "tablet", - pillForm: normalizedPillForm, - lifecycleCategory: lifecycleCategory ?? "refill_when_empty", - packageType: normalizePackageType(packageType), + genericName, + takenBy, + medicationForm, + pillForm, + lifecycleCategory, + packageType, packCount, blistersPerPack, pillsPerBlister, packageAmountValue, packageAmountUnit, - totalPills: totalPills || null, + totalPills, looseTablets, - pillWeightMg: pillWeightMg || null, - doseUnit: doseUnit ?? "mg", - medicationStartDate: medicationStartDate ?? "", - medicationEndDate: medicationEndDate || null, - autoMarkObsoleteAfterEndDate: autoMarkObsoleteAfterEndDate ?? true, - expiryDate: expiryDate || null, - notes: notes || null, - prescriptionEnabled: prescriptionEnabled ?? false, - prescriptionAuthorizedRefills: prescriptionEnabled ? (prescriptionAuthorizedRefills ?? null) : null, - prescriptionRemainingRefills: prescriptionEnabled ? (prescriptionRemainingRefills ?? null) : null, - prescriptionLowRefillThreshold: prescriptionLowRefillThreshold ?? 1, - prescriptionExpiryDate: prescriptionExpiryDate || null, - intakeRemindersEnabled: intakeRemindersEnabled ?? false, - intakesJson, - usageJson, - everyJson, - startJson, - }) - .returning(); + pillWeightMg, + doseUnit, + medicationStartDate, + medicationEndDate, + autoMarkObsoleteAfterEndDate, + expiryDate, + notes, + prescriptionEnabled, + prescriptionAuthorizedRefills, + prescriptionRemainingRefills, + prescriptionLowRefillThreshold, + prescriptionExpiryDate, + intakeRemindersEnabled, + intakes: inputIntakes, + blisters: inputBlisters, + } = parsed.data; - return { - id: inserted.id, - name: inserted.name, - genericName: inserted.genericName, - takenBy: parseTakenByJson(inserted.takenByJson), - medicationForm: inserted.medicationForm ?? "tablet", - pillForm: inserted.pillForm ?? null, - lifecycleCategory: inserted.lifecycleCategory ?? "refill_when_empty", - packageType: normalizePackageType(inserted.packageType), - packCount: inserted.packCount, - blistersPerPack: inserted.blistersPerPack, - pillsPerBlister: inserted.pillsPerBlister, - packageAmountValue: inserted.packageAmountValue ?? 0, - packageAmountUnit: (inserted.packageAmountUnit ?? "ml") as "ml" | "g", - totalPills: inserted.totalPills ?? null, - looseTablets: inserted.looseTablets, - stockAdjustment: inserted.stockAdjustment ?? 0, - lastStockCorrectionAt: inserted.lastStockCorrectionAt?.toISOString() ?? null, - pillWeightMg: inserted.pillWeightMg, - doseUnit: inserted.doseUnit ?? "mg", - medicationStartDate: inserted.medicationStartDate || null, - medicationEndDate: inserted.medicationEndDate || null, - autoMarkObsoleteAfterEndDate: inserted.autoMarkObsoleteAfterEndDate ?? true, - intakes, - blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })), - imageUrl: inserted.imageUrl, - expiryDate: inserted.expiryDate, - notes: inserted.notes, - intakeRemindersEnabled: inserted.intakeRemindersEnabled, - isObsolete: inserted.isObsolete ?? false, - obsoleteAt: inserted.obsoleteAt?.toISOString() ?? null, - prescriptionEnabled: inserted.prescriptionEnabled ?? false, - prescriptionAuthorizedRefills: inserted.prescriptionAuthorizedRefills ?? null, - prescriptionRemainingRefills: inserted.prescriptionRemainingRefills ?? null, - prescriptionLowRefillThreshold: inserted.prescriptionLowRefillThreshold ?? 1, - prescriptionExpiryDate: inserted.prescriptionExpiryDate ?? null, - updatedAt: inserted.updatedAt, - }; - }); + const normalizedPillForm = + medicationForm === "capsule" || medicationForm === "tablet" ? (pillForm ?? medicationForm) : null; - app.put<{ Params: { id: string } }>("/medications/:id", async (req, reply) => { - const parsed = medicationSchema.safeParse(req.body); - if (!parsed.success) return reply.status(400).send(parsed.error.format()); - const idNum = Number(req.params.id); - if (Number.isNaN(idNum)) return reply.badRequest("Invalid id"); + // Convert to unified intakes format + let intakes: Intake[]; + if (inputIntakes) { + // New format with per-intake takenBy + intakes = inputIntakes.map((i) => ({ + usage: i.usage, + every: i.every, + start: i.start, + intakeUnit: i.intakeUnit ?? null, + takenBy: i.takenBy || null, + intakeRemindersEnabled: i.intakeRemindersEnabled ?? false, + })); + } else if (inputBlisters) { + // Legacy format - convert to new format + intakes = inputBlisters.map((b) => ({ + usage: b.usage, + every: b.every, + start: b.start, + intakeUnit: null, + takenBy: null, // No per-intake takenBy from legacy + intakeRemindersEnabled: intakeRemindersEnabled ?? false, + })); + } else { + return reply.status(400).send({ error: "Either 'intakes' or 'blisters' must be provided" }); + } - const userId = await getUserId(req, reply); + // Store both formats for backward compatibility + const intakesJson = JSON.stringify(intakes); + const usageJson = JSON.stringify(intakes.map((s) => s.usage)); + const everyJson = JSON.stringify(intakes.map((s) => s.every)); + const startJson = JSON.stringify(intakes.map((s) => s.start)); + const takenByJson = JSON.stringify(takenBy || []); - // Verify ownership - const [existing] = await db - .select() - .from(medications) - .where(and(eq(medications.id, idNum), eq(medications.userId, userId))); - if (!existing) return reply.notFound(); + // If stock-defining fields changed, reset stockAdjustment so the new + // base stock reflects actual inventory. This prevents the old + // correction offset from skewing the total after an edit. + const stockFieldsChanged = + existing.packCount !== packCount || + existing.blistersPerPack !== blistersPerPack || + existing.pillsPerBlister !== pillsPerBlister || + (existing.looseTablets ?? 0) !== (looseTablets ?? 0); - const { - name, - genericName, - takenBy, - medicationForm, - pillForm, - lifecycleCategory, - packageType, - packCount, - blistersPerPack, - pillsPerBlister, - packageAmountValue, - packageAmountUnit, - totalPills, - looseTablets, - pillWeightMg, - doseUnit, - medicationStartDate, - medicationEndDate, - autoMarkObsoleteAfterEndDate, - expiryDate, - notes, - prescriptionEnabled, - prescriptionAuthorizedRefills, - prescriptionRemainingRefills, - prescriptionLowRefillThreshold, - prescriptionExpiryDate, - intakeRemindersEnabled, - intakes: inputIntakes, - blisters: inputBlisters, - } = parsed.data; + const stockResetFields = stockFieldsChanged ? { stockAdjustment: 0, lastStockCorrectionAt: new Date() } : {}; - const normalizedPillForm = - medicationForm === "capsule" || medicationForm === "tablet" ? (pillForm ?? medicationForm) : null; + const result = await db + .update(medications) + .set({ + name, + genericName: genericName || null, + takenByJson, + medicationForm: medicationForm ?? "tablet", + pillForm: normalizedPillForm, + lifecycleCategory: lifecycleCategory ?? "refill_when_empty", + packageType: normalizePackageType(packageType), + packCount, + blistersPerPack, + pillsPerBlister, + totalPills: totalPills || null, + packageAmountValue, + packageAmountUnit, + looseTablets, + pillWeightMg: pillWeightMg || null, + doseUnit: doseUnit ?? "mg", + medicationStartDate: medicationStartDate ?? "", + medicationEndDate: medicationEndDate || null, + autoMarkObsoleteAfterEndDate: autoMarkObsoleteAfterEndDate ?? true, + expiryDate: expiryDate || null, + notes: notes || null, + prescriptionEnabled: prescriptionEnabled ?? false, + prescriptionAuthorizedRefills: prescriptionEnabled ? (prescriptionAuthorizedRefills ?? null) : null, + prescriptionRemainingRefills: prescriptionEnabled ? (prescriptionRemainingRefills ?? null) : null, + prescriptionLowRefillThreshold: prescriptionLowRefillThreshold ?? 1, + prescriptionExpiryDate: prescriptionExpiryDate || null, + intakeRemindersEnabled: intakeRemindersEnabled ?? false, + intakesJson, + usageJson, + everyJson, + startJson, + updatedAt: new Date(), + ...stockResetFields, + }) + .where(and(eq(medications.id, idNum), eq(medications.userId, userId))) + .returning(); - // Convert to unified intakes format - let intakes: Intake[]; - if (inputIntakes) { - // New format with per-intake takenBy - intakes = inputIntakes.map((i) => ({ - usage: i.usage, - every: i.every, - start: i.start, - intakeUnit: i.intakeUnit ?? null, - takenBy: i.takenBy || null, - intakeRemindersEnabled: i.intakeRemindersEnabled ?? false, - })); - } else if (inputBlisters) { - // Legacy format - convert to new format - intakes = inputBlisters.map((b) => ({ - usage: b.usage, - every: b.every, - start: b.start, - intakeUnit: null, - takenBy: null, // No per-intake takenBy from legacy - intakeRemindersEnabled: intakeRemindersEnabled ?? false, - })); - } else { - return reply.status(400).send({ error: "Either 'intakes' or 'blisters' must be provided" }); - } + if (!result.length) return reply.notFound(); - // Store both formats for backward compatibility - const intakesJson = JSON.stringify(intakes); - const usageJson = JSON.stringify(intakes.map((s) => s.usage)); - const everyJson = JSON.stringify(intakes.map((s) => s.every)); - const startJson = JSON.stringify(intakes.map((s) => s.start)); - const takenByJson = JSON.stringify(takenBy || []); + // --------------------------------------------------------------- + // Migrate dose tracking IDs when intake schedule changes + // --------------------------------------------------------------- + // Parse old intakes from the existing medication row + const oldIntakes = parseIntakesWithUnits( + existing.intakesJson, + { usageJson: existing.usageJson, everyJson: existing.everyJson, startJson: existing.startJson }, + existing.intakeRemindersEnabled + ); - // If stock-defining fields changed, reset stockAdjustment so the new - // base stock reflects actual inventory. This prevents the old - // correction offset from skewing the total after an edit. - const stockFieldsChanged = - existing.packCount !== packCount || - existing.blistersPerPack !== blistersPerPack || - existing.pillsPerBlister !== pillsPerBlister || - (existing.looseTablets ?? 0) !== (looseTablets ?? 0); + // Get all dose tracking entries for this medication + const allDoses = await db + .select() + .from(doseTracking) + .where(and(eq(doseTracking.userId, userId), like(doseTracking.doseId, `${idNum}-%`))); - const stockResetFields = stockFieldsChanged ? { stockAdjustment: 0, lastStockCorrectionAt: new Date() } : {}; + if (allDoses.length > 0) { + // Build migration map: for each intake index, map old dateOnlyMs → new dateOnlyMs + const now = new Date(); + const migrationEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const MS_PER_DAY = 86_400_000; - const result = await db - .update(medications) - .set({ - name, - genericName: genericName || null, - takenByJson, - medicationForm: medicationForm ?? "tablet", - pillForm: normalizedPillForm, - lifecycleCategory: lifecycleCategory ?? "refill_when_empty", - packageType: normalizePackageType(packageType), - packCount, - blistersPerPack, - pillsPerBlister, - totalPills: totalPills || null, - packageAmountValue, - packageAmountUnit, - looseTablets, - pillWeightMg: pillWeightMg || null, - doseUnit: doseUnit ?? "mg", - medicationStartDate: medicationStartDate ?? "", - medicationEndDate: medicationEndDate || null, - autoMarkObsoleteAfterEndDate: autoMarkObsoleteAfterEndDate ?? true, - expiryDate: expiryDate || null, - notes: notes || null, - prescriptionEnabled: prescriptionEnabled ?? false, - prescriptionAuthorizedRefills: prescriptionEnabled ? (prescriptionAuthorizedRefills ?? null) : null, - prescriptionRemainingRefills: prescriptionEnabled ? (prescriptionRemainingRefills ?? null) : null, - prescriptionLowRefillThreshold: prescriptionLowRefillThreshold ?? 1, - prescriptionExpiryDate: prescriptionExpiryDate || null, - intakeRemindersEnabled: intakeRemindersEnabled ?? false, - intakesJson, - usageJson, - everyJson, - startJson, - updatedAt: new Date(), - ...stockResetFields, - }) - .where(and(eq(medications.id, idNum), eq(medications.userId, userId))) - .returning(); + for (let idx = 0; idx < Math.max(oldIntakes.length, intakes.length); idx++) { + const oldIntake = oldIntakes[idx]; + const newIntake = intakes[idx]; - if (!result.length) return reply.notFound(); + // Skip if this intake index doesn't exist in both old and new + if (!oldIntake || !newIntake) continue; - // --------------------------------------------------------------- - // Migrate dose tracking IDs when intake schedule changes - // --------------------------------------------------------------- - // Parse old intakes from the existing medication row - const oldIntakes = parseIntakesWithUnits( - existing.intakesJson, - { usageJson: existing.usageJson, everyJson: existing.everyJson, startJson: existing.startJson }, - existing.intakeRemindersEnabled - ); + const oldStart = parseLocalDateTime(oldIntake.start); + const newStart = parseLocalDateTime(newIntake.start); + const oldEvery = oldIntake.every; + const newEvery = newIntake.every; - // Get all dose tracking entries for this medication - const allDoses = await db - .select() - .from(doseTracking) - .where(and(eq(doseTracking.userId, userId), like(doseTracking.doseId, `${idNum}-%`))); + // Check if start date or interval changed (time-of-day changes don't matter for dateOnlyMs) + const oldStartDateOnly = new Date(oldStart.getFullYear(), oldStart.getMonth(), oldStart.getDate()).getTime(); + const newStartDateOnly = new Date(newStart.getFullYear(), newStart.getMonth(), newStart.getDate()).getTime(); - if (allDoses.length > 0) { - // Build migration map: for each intake index, map old dateOnlyMs → new dateOnlyMs - const now = new Date(); - const migrationEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - const MS_PER_DAY = 86_400_000; + if (oldStartDateOnly === newStartDateOnly && oldEvery === newEvery) { + continue; // No schedule change that affects dose IDs + } - for (let idx = 0; idx < Math.max(oldIntakes.length, intakes.length); idx++) { - const oldIntake = oldIntakes[idx]; - const newIntake = intakes[idx]; + // Build set of new valid dateOnlyMs values for this intake + const newDates = new Set(); + for (let d = new Date(newStart); d <= migrationEnd; d.setDate(d.getDate() + newEvery)) { + newDates.add(new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()); + } - // Skip if this intake index doesn't exist in both old and new - if (!oldIntake || !newIntake) continue; - - const oldStart = parseLocalDateTime(oldIntake.start); - const newStart = parseLocalDateTime(newIntake.start); - const oldEvery = oldIntake.every; - const newEvery = newIntake.every; - - // Check if start date or interval changed (time-of-day changes don't matter for dateOnlyMs) - const oldStartDateOnly = new Date(oldStart.getFullYear(), oldStart.getMonth(), oldStart.getDate()).getTime(); - const newStartDateOnly = new Date(newStart.getFullYear(), newStart.getMonth(), newStart.getDate()).getTime(); - - if (oldStartDateOnly === newStartDateOnly && oldEvery === newEvery) { - continue; // No schedule change that affects dose IDs - } - - // Build set of new valid dateOnlyMs values for this intake - const newDates = new Set(); - for (let d = new Date(newStart); d <= migrationEnd; d.setDate(d.getDate() + newEvery)) { - newDates.add(new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()); - } - - // Build set of old dateOnlyMs values with mapping to nearest new date - const oldToNewMap = new Map(); - for (let d = new Date(oldStart); d <= migrationEnd; d.setDate(d.getDate() + oldEvery)) { - const oldDateMs = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); - // Find the closest new date within ±(newEvery/2) days - const halfInterval = (newEvery * MS_PER_DAY) / 2; - let bestMatch: number | null = null; - let bestDist = Infinity; - for (const newDateMs of newDates) { - const dist = Math.abs(newDateMs - oldDateMs); - if (dist < bestDist && dist <= halfInterval) { - bestDist = dist; - bestMatch = newDateMs; + // Build set of old dateOnlyMs values with mapping to nearest new date + const oldToNewMap = new Map(); + for (let d = new Date(oldStart); d <= migrationEnd; d.setDate(d.getDate() + oldEvery)) { + const oldDateMs = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); + // Find the closest new date within ±(newEvery/2) days + const halfInterval = (newEvery * MS_PER_DAY) / 2; + let bestMatch: number | null = null; + let bestDist = Infinity; + for (const newDateMs of newDates) { + const dist = Math.abs(newDateMs - oldDateMs); + if (dist < bestDist && dist <= halfInterval) { + bestDist = dist; + bestMatch = newDateMs; + } + } + if (bestMatch !== null && bestMatch !== oldDateMs) { + oldToNewMap.set(oldDateMs, bestMatch); + // Remove matched new date to prevent double-mapping + newDates.delete(bestMatch); } } - if (bestMatch !== null && bestMatch !== oldDateMs) { - oldToNewMap.set(oldDateMs, bestMatch); - // Remove matched new date to prevent double-mapping - newDates.delete(bestMatch); - } - } - // Apply migrations to dose tracking entries - if (oldToNewMap.size > 0) { - const prefix = `${idNum}-${idx}-`; - const dosesToMigrate = allDoses.filter((d) => d.doseId.startsWith(prefix)); + // Apply migrations to dose tracking entries + if (oldToNewMap.size > 0) { + const prefix = `${idNum}-${idx}-`; + const dosesToMigrate = allDoses.filter((d) => d.doseId.startsWith(prefix)); - for (const dose of dosesToMigrate) { - const parts = dose.doseId.split("-"); - if (parts.length >= 3) { - const oldTimestamp = parseInt(parts[2], 10); - const newTimestamp = oldToNewMap.get(oldTimestamp); - if (newTimestamp !== undefined) { - // Replace the timestamp in the dose ID, keeping any person suffix - const newDoseId = `${idNum}-${idx}-${newTimestamp}${parts.length > 3 ? `-${parts.slice(3).join("-")}` : ""}`; - await db.update(doseTracking).set({ doseId: newDoseId }).where(eq(doseTracking.id, dose.id)); + for (const dose of dosesToMigrate) { + const parts = dose.doseId.split("-"); + if (parts.length >= 3) { + const oldTimestamp = parseInt(parts[2], 10); + const newTimestamp = oldToNewMap.get(oldTimestamp); + if (newTimestamp !== undefined) { + // Replace the timestamp in the dose ID, keeping any person suffix + const newDoseId = `${idNum}-${idx}-${newTimestamp}${parts.length > 3 ? `-${parts.slice(3).join("-")}` : ""}`; + await db.update(doseTracking).set({ doseId: newDoseId }).where(eq(doseTracking.id, dose.id)); + } } } } } - } - // Also clean up dose tracking entries before the earliest new start date - const earliestStartDate = intakes.reduce((min, b) => { - const d = parseLocalDateTime(b.start); - // Use date-only (midnight) to match dose ID format - const dateOnly = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); - return dateOnly < min ? dateOnly : min; - }, Infinity); - if (!Number.isNaN(earliestStartDate)) { - // Re-fetch after possible migrations - const updatedDoses = await db - .select() - .from(doseTracking) - .where(and(eq(doseTracking.userId, userId), like(doseTracking.doseId, `${idNum}-%`))); + // Also clean up dose tracking entries before the earliest new start date + const earliestStartDate = intakes.reduce((min, b) => { + const d = parseLocalDateTime(b.start); + // Use date-only (midnight) to match dose ID format + const dateOnly = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); + return dateOnly < min ? dateOnly : min; + }, Infinity); + if (!Number.isNaN(earliestStartDate)) { + // Re-fetch after possible migrations + const updatedDoses = await db + .select() + .from(doseTracking) + .where(and(eq(doseTracking.userId, userId), like(doseTracking.doseId, `${idNum}-%`))); - const dosesToDelete = updatedDoses.filter((dose) => { - const parts = dose.doseId.split("-"); - if (parts.length >= 3) { - const timestamp = parseInt(parts[2], 10); - return !Number.isNaN(timestamp) && timestamp < earliestStartDate; + const dosesToDelete = updatedDoses.filter((dose) => { + const parts = dose.doseId.split("-"); + if (parts.length >= 3) { + const timestamp = parseInt(parts[2], 10); + return !Number.isNaN(timestamp) && timestamp < earliestStartDate; + } + return false; + }); + + for (const dose of dosesToDelete) { + await db.delete(doseTracking).where(eq(doseTracking.id, dose.id)); } - return false; - }); - - for (const dose of dosesToDelete) { - await db.delete(doseTracking).where(eq(doseTracking.id, dose.id)); } } + + return { + id: result[0].id, + name: result[0].name, + genericName: result[0].genericName, + takenBy: parseTakenByJson(result[0].takenByJson), + medicationForm: result[0].medicationForm ?? "tablet", + pillForm: result[0].pillForm ?? null, + lifecycleCategory: result[0].lifecycleCategory ?? "refill_when_empty", + packageType: normalizePackageType(result[0].packageType), + packCount: result[0].packCount, + blistersPerPack: result[0].blistersPerPack, + pillsPerBlister: result[0].pillsPerBlister, + packageAmountValue: result[0].packageAmountValue ?? 0, + packageAmountUnit: (result[0].packageAmountUnit ?? "ml") as "ml" | "g", + totalPills: result[0].totalPills ?? null, + looseTablets: result[0].looseTablets, + stockAdjustment: result[0].stockAdjustment ?? 0, + lastStockCorrectionAt: result[0].lastStockCorrectionAt?.toISOString() ?? null, + pillWeightMg: result[0].pillWeightMg, + doseUnit: result[0].doseUnit ?? "mg", + medicationStartDate: result[0].medicationStartDate || null, + medicationEndDate: result[0].medicationEndDate || null, + autoMarkObsoleteAfterEndDate: result[0].autoMarkObsoleteAfterEndDate ?? true, + intakes, + blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })), + imageUrl: result[0].imageUrl, + expiryDate: result[0].expiryDate, + notes: result[0].notes, + intakeRemindersEnabled: result[0].intakeRemindersEnabled, + isObsolete: result[0].isObsolete ?? false, + obsoleteAt: result[0].obsoleteAt?.toISOString() ?? null, + prescriptionEnabled: result[0].prescriptionEnabled ?? false, + prescriptionAuthorizedRefills: result[0].prescriptionAuthorizedRefills ?? null, + prescriptionRemainingRefills: result[0].prescriptionRemainingRefills ?? null, + prescriptionLowRefillThreshold: result[0].prescriptionLowRefillThreshold ?? 1, + prescriptionExpiryDate: result[0].prescriptionExpiryDate ?? null, + updatedAt: normalizeDateTime(result[0].updatedAt), + }; } + ); - return { - id: result[0].id, - name: result[0].name, - genericName: result[0].genericName, - takenBy: parseTakenByJson(result[0].takenByJson), - medicationForm: result[0].medicationForm ?? "tablet", - pillForm: result[0].pillForm ?? null, - lifecycleCategory: result[0].lifecycleCategory ?? "refill_when_empty", - packageType: normalizePackageType(result[0].packageType), - packCount: result[0].packCount, - blistersPerPack: result[0].blistersPerPack, - pillsPerBlister: result[0].pillsPerBlister, - packageAmountValue: result[0].packageAmountValue ?? 0, - packageAmountUnit: (result[0].packageAmountUnit ?? "ml") as "ml" | "g", - totalPills: result[0].totalPills ?? null, - looseTablets: result[0].looseTablets, - stockAdjustment: result[0].stockAdjustment ?? 0, - lastStockCorrectionAt: result[0].lastStockCorrectionAt?.toISOString() ?? null, - pillWeightMg: result[0].pillWeightMg, - doseUnit: result[0].doseUnit ?? "mg", - medicationStartDate: result[0].medicationStartDate || null, - medicationEndDate: result[0].medicationEndDate || null, - autoMarkObsoleteAfterEndDate: result[0].autoMarkObsoleteAfterEndDate ?? true, - intakes, - blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })), - imageUrl: result[0].imageUrl, - expiryDate: result[0].expiryDate, - notes: result[0].notes, - intakeRemindersEnabled: result[0].intakeRemindersEnabled, - isObsolete: result[0].isObsolete ?? false, - obsoleteAt: result[0].obsoleteAt?.toISOString() ?? null, - prescriptionEnabled: result[0].prescriptionEnabled ?? false, - prescriptionAuthorizedRefills: result[0].prescriptionAuthorizedRefills ?? null, - prescriptionRemainingRefills: result[0].prescriptionRemainingRefills ?? null, - prescriptionLowRefillThreshold: result[0].prescriptionLowRefillThreshold ?? 1, - prescriptionExpiryDate: result[0].prescriptionExpiryDate ?? null, - updatedAt: result[0].updatedAt, - }; - }); + app.post<{ Params: { id: string } }>( + "/medications/:id/obsolete", + { + schema: { + params: idParamsSchema, + response: { + 200: obsoleteStateResponseSchema, + 400: genericErrorSchema, + 401: genericErrorSchema, + 404: genericErrorSchema, + }, + }, + }, + async (req, reply) => { + const idNum = Number(req.params.id); + if (Number.isNaN(idNum)) return reply.badRequest("Invalid id"); - app.post<{ Params: { id: string } }>("/medications/:id/obsolete", async (req, reply) => { - const idNum = Number(req.params.id); - if (Number.isNaN(idNum)) return reply.badRequest("Invalid id"); + const userId = await getUserId(req, reply); + const [existing] = await db + .select() + .from(medications) + .where(and(eq(medications.id, idNum), eq(medications.userId, userId))); + if (!existing) return reply.notFound(); - const userId = await getUserId(req, reply); - const [existing] = await db - .select() - .from(medications) - .where(and(eq(medications.id, idNum), eq(medications.userId, userId))); - if (!existing) return reply.notFound(); + const [updated] = await db + .update(medications) + .set({ + isObsolete: true, + obsoleteAt: new Date(), + updatedAt: new Date(), + }) + .where(and(eq(medications.id, idNum), eq(medications.userId, userId))) + .returning(); - const [updated] = await db - .update(medications) - .set({ - isObsolete: true, - obsoleteAt: new Date(), - updatedAt: new Date(), - }) - .where(and(eq(medications.id, idNum), eq(medications.userId, userId))) - .returning(); + return { + id: updated.id, + isObsolete: updated.isObsolete ?? false, + obsoleteAt: updated.obsoleteAt?.toISOString() ?? null, + updatedAt: normalizeDateTime(updated.updatedAt), + }; + } + ); - return { - id: updated.id, - isObsolete: updated.isObsolete ?? false, - obsoleteAt: updated.obsoleteAt?.toISOString() ?? null, - updatedAt: updated.updatedAt, - }; - }); + app.post<{ Params: { id: string } }>( + "/medications/:id/reactivate", + { + schema: { + params: idParamsSchema, + response: { + 200: obsoleteStateResponseSchema, + 400: genericErrorSchema, + 401: genericErrorSchema, + 404: genericErrorSchema, + }, + }, + }, + async (req, reply) => { + const idNum = Number(req.params.id); + if (Number.isNaN(idNum)) return reply.badRequest("Invalid id"); - app.post<{ Params: { id: string } }>("/medications/:id/reactivate", async (req, reply) => { - const idNum = Number(req.params.id); - if (Number.isNaN(idNum)) return reply.badRequest("Invalid id"); + const userId = await getUserId(req, reply); + const [existing] = await db + .select() + .from(medications) + .where(and(eq(medications.id, idNum), eq(medications.userId, userId))); + if (!existing) return reply.notFound(); - const userId = await getUserId(req, reply); - const [existing] = await db - .select() - .from(medications) - .where(and(eq(medications.id, idNum), eq(medications.userId, userId))); - if (!existing) return reply.notFound(); + const [updated] = await db + .update(medications) + .set({ + isObsolete: false, + obsoleteAt: null, + updatedAt: new Date(), + }) + .where(and(eq(medications.id, idNum), eq(medications.userId, userId))) + .returning(); - const [updated] = await db - .update(medications) - .set({ - isObsolete: false, - obsoleteAt: null, - updatedAt: new Date(), - }) - .where(and(eq(medications.id, idNum), eq(medications.userId, userId))) - .returning(); - - return { - id: updated.id, - isObsolete: updated.isObsolete ?? false, - obsoleteAt: updated.obsoleteAt?.toISOString() ?? null, - updatedAt: updated.updatedAt, - }; - }); + return { + id: updated.id, + isObsolete: updated.isObsolete ?? false, + obsoleteAt: updated.obsoleteAt?.toISOString() ?? null, + updatedAt: normalizeDateTime(updated.updatedAt), + }; + } + ); // Stock correction endpoint - updates stockAdjustment and optionally base amount fields for amount-based corrections // Also sets lastStockCorrectionAt so consumed doses before this point don't count @@ -853,430 +1180,507 @@ export async function medicationRoutes(app: FastifyInstance) { packageAmountValue?: number; packCount?: number; }; - }>("/medications/:id/stock-adjustment", async (req, reply) => { - const idNum = Number(req.params.id); - if (Number.isNaN(idNum)) return reply.badRequest("Invalid id"); + }>( + "/medications/:id/stock-adjustment", + { + schema: { + params: idParamsSchema, + body: stockAdjustmentBodySchema, + response: { + 200: stockAdjustmentResponseSchema, + 400: genericErrorSchema, + 401: genericErrorSchema, + 404: genericErrorSchema, + }, + }, + }, + async (req, reply) => { + const idNum = Number(req.params.id); + if (Number.isNaN(idNum)) return reply.badRequest("Invalid id"); - const userId = await getUserId(req, reply); + const userId = await getUserId(req, reply); - // Verify ownership - const [existing] = await db - .select() - .from(medications) - .where(and(eq(medications.id, idNum), eq(medications.userId, userId))); - if (!existing) return reply.notFound(); + // Verify ownership + const [existing] = await db + .select() + .from(medications) + .where(and(eq(medications.id, idNum), eq(medications.userId, userId))); + if (!existing) return reply.notFound(); - const { stockAdjustment, looseTablets, totalPills, packageAmountValue, packCount } = req.body as { - stockAdjustment: number; - looseTablets?: number; - totalPills?: number; - packageAmountValue?: number; - packCount?: number; - }; - if (typeof stockAdjustment !== "number") return reply.badRequest("stockAdjustment must be a number"); - if ( - looseTablets !== undefined && - (typeof looseTablets !== "number" || !Number.isInteger(looseTablets) || looseTablets < 0) - ) { - return reply.badRequest("looseTablets must be a non-negative integer"); + const { stockAdjustment, looseTablets, totalPills, packageAmountValue, packCount } = req.body as { + stockAdjustment: number; + looseTablets?: number; + totalPills?: number; + packageAmountValue?: number; + packCount?: number; + }; + if (typeof stockAdjustment !== "number") return reply.badRequest("stockAdjustment must be a number"); + if ( + looseTablets !== undefined && + (typeof looseTablets !== "number" || !Number.isInteger(looseTablets) || looseTablets < 0) + ) { + return reply.badRequest("looseTablets must be a non-negative integer"); + } + if ( + totalPills !== undefined && + (typeof totalPills !== "number" || !Number.isInteger(totalPills) || totalPills < 0) + ) { + return reply.badRequest("totalPills must be a non-negative integer"); + } + if ( + packageAmountValue !== undefined && + (typeof packageAmountValue !== "number" || !Number.isInteger(packageAmountValue) || packageAmountValue < 0) + ) { + return reply.badRequest("packageAmountValue must be a non-negative integer"); + } + if (packCount !== undefined && (typeof packCount !== "number" || !Number.isInteger(packCount) || packCount < 1)) { + return reply.badRequest("packCount must be an integer >= 1"); + } + + const updateFields: { + stockAdjustment: number; + lastStockCorrectionAt: Date; + updatedAt: Date; + looseTablets?: number; + totalPills?: number | null; + packageAmountValue?: number; + packCount?: number; + } = { + stockAdjustment, + lastStockCorrectionAt: new Date(), + updatedAt: new Date(), + }; + + const packageType = normalizePackageType(existing.packageType); + const allowsAmountBaseUpdate = isTubePackageType(packageType) || isLiquidContainerPackageType(packageType); + if (allowsAmountBaseUpdate) { + if (totalPills !== undefined) updateFields.totalPills = totalPills; + if (looseTablets !== undefined) updateFields.looseTablets = looseTablets; + if (packageAmountValue !== undefined) updateFields.packageAmountValue = packageAmountValue; + if (packCount !== undefined) updateFields.packCount = packCount; + } + if (looseTablets !== undefined) { + updateFields.looseTablets = looseTablets; + } + + const result = await db + .update(medications) + .set(updateFields) + .where(and(eq(medications.id, idNum), eq(medications.userId, userId))) + .returning(); + + if (!result.length) return reply.notFound(); + + return { + id: result[0].id, + stockAdjustment: result[0].stockAdjustment ?? 0, + lastStockCorrectionAt: result[0].lastStockCorrectionAt?.toISOString() ?? null, + updatedAt: normalizeDateTime(result[0].updatedAt), + }; } - if ( - totalPills !== undefined && - (typeof totalPills !== "number" || !Number.isInteger(totalPills) || totalPills < 0) - ) { - return reply.badRequest("totalPills must be a non-negative integer"); + ); + + app.delete<{ Params: { id: string } }>( + "/medications/:id", + { + schema: { + params: idParamsSchema, + response: { + 204: { type: "null" }, + 400: genericErrorSchema, + 401: genericErrorSchema, + 404: genericErrorSchema, + }, + }, + }, + async (req, reply) => { + const idNum = Number(req.params.id); + if (Number.isNaN(idNum)) return reply.badRequest("Invalid id"); + + const userId = await getUserId(req, reply); + + // Delete associated image if exists (with ownership check) + const [existing] = await db + .select() + .from(medications) + .where(and(eq(medications.id, idNum), eq(medications.userId, userId))); + if (!existing) return reply.notFound(); + + if (existing.imageUrl) removeImageFiles(IMAGES_DIR, existing.imageUrl); + + const deleted = await db + .delete(medications) + .where(and(eq(medications.id, idNum), eq(medications.userId, userId))) + .returning(); + if (!deleted.length) return reply.notFound(); + return reply.status(204).send(); } - if ( - packageAmountValue !== undefined && - (typeof packageAmountValue !== "number" || !Number.isInteger(packageAmountValue) || packageAmountValue < 0) - ) { - return reply.badRequest("packageAmountValue must be a non-negative integer"); - } - if (packCount !== undefined && (typeof packCount !== "number" || !Number.isInteger(packCount) || packCount < 1)) { - return reply.badRequest("packCount must be an integer >= 1"); - } - - const updateFields: { - stockAdjustment: number; - lastStockCorrectionAt: Date; - updatedAt: Date; - looseTablets?: number; - totalPills?: number | null; - packageAmountValue?: number; - packCount?: number; - } = { - stockAdjustment, - lastStockCorrectionAt: new Date(), - updatedAt: new Date(), - }; - - const packageType = normalizePackageType(existing.packageType); - const allowsAmountBaseUpdate = isTubePackageType(packageType) || isLiquidContainerPackageType(packageType); - if (allowsAmountBaseUpdate) { - if (totalPills !== undefined) updateFields.totalPills = totalPills; - if (looseTablets !== undefined) updateFields.looseTablets = looseTablets; - if (packageAmountValue !== undefined) updateFields.packageAmountValue = packageAmountValue; - if (packCount !== undefined) updateFields.packCount = packCount; - } - if (looseTablets !== undefined) { - updateFields.looseTablets = looseTablets; - } - - const result = await db - .update(medications) - .set(updateFields) - .where(and(eq(medications.id, idNum), eq(medications.userId, userId))) - .returning(); - - if (!result.length) return reply.notFound(); - - return { - id: result[0].id, - stockAdjustment: result[0].stockAdjustment ?? 0, - lastStockCorrectionAt: result[0].lastStockCorrectionAt?.toISOString() ?? null, - updatedAt: result[0].updatedAt, - }; - }); - - app.delete<{ Params: { id: string } }>("/medications/:id", async (req, reply) => { - const idNum = Number(req.params.id); - if (Number.isNaN(idNum)) return reply.badRequest("Invalid id"); - - const userId = await getUserId(req, reply); - - // Delete associated image if exists (with ownership check) - const [existing] = await db - .select() - .from(medications) - .where(and(eq(medications.id, idNum), eq(medications.userId, userId))); - if (!existing) return reply.notFound(); - - if (existing.imageUrl) removeImageFiles(IMAGES_DIR, existing.imageUrl); - - const deleted = await db - .delete(medications) - .where(and(eq(medications.id, idNum), eq(medications.userId, userId))) - .returning(); - if (!deleted.length) return reply.notFound(); - return reply.status(204).send(); - }); + ); // Upload medication image - app.post<{ Params: { id: string } }>("/medications/:id/image", async (req, reply) => { - const idNum = Number(req.params.id); - if (Number.isNaN(idNum)) return reply.badRequest("Invalid id"); + app.post<{ Params: { id: string } }>( + "/medications/:id/image", + { + schema: { + params: idParamsSchema, + consumes: ["multipart/form-data"], + response: { + 200: { + type: "object", + properties: { + success: { type: "boolean" }, + imageUrl: { type: "string" }, + }, + }, + 400: genericErrorSchema, + 401: genericErrorSchema, + 404: genericErrorSchema, + }, + }, + }, + async (req, reply) => { + const idNum = Number(req.params.id); + if (Number.isNaN(idNum)) return reply.badRequest("Invalid id"); - const userId = await getUserId(req, reply); - const [existing] = await db - .select() - .from(medications) - .where(and(eq(medications.id, idNum), eq(medications.userId, userId))); - if (!existing) return reply.notFound(); + const userId = await getUserId(req, reply); + const [existing] = await db + .select() + .from(medications) + .where(and(eq(medications.id, idNum), eq(medications.userId, userId))); + if (!existing) return reply.notFound(); - const data = await req.file(); - if (!data) return reply.status(400).send({ error: "No file uploaded", code: "NO_FILE" }); + const data = await req.file(); + if (!data) return reply.status(400).send({ error: "No file uploaded", code: "NO_FILE" }); - if (!ALLOWED_IMAGE_MIME_TYPES.includes(data.mimetype)) { - return reply.status(400).send({ error: "Invalid file type", code: "INVALID_TYPE" }); - } - - let uploadBuffer: Buffer; - try { - uploadBuffer = await streamToBuffer(data.file); - } catch (error) { - if (error instanceof Error && error.message === "IMAGE_TOO_LARGE") { - return reply.status(400).send({ error: "Image too large", code: "IMAGE_TOO_LARGE" }); + if (!ALLOWED_IMAGE_MIME_TYPES.includes(data.mimetype)) { + return reply.status(400).send({ error: "Invalid file type", code: "INVALID_TYPE" }); } - throw error; + + let uploadBuffer: Buffer; + try { + uploadBuffer = await streamToBuffer(data.file); + } catch (error) { + if (error instanceof Error && error.message === "IMAGE_TOO_LARGE") { + return reply.status(400).send({ error: "Image too large", code: "IMAGE_TOO_LARGE" }); + } + throw error; + } + + let filename: string; + try { + ({ filename } = await writeOptimizedImageSet(IMAGES_DIR, `med-${idNum}`, uploadBuffer)); + } catch { + return reply.status(400).send({ error: "Invalid image", code: "INVALID_IMAGE" }); + } + + // Delete old image if exists + if (existing.imageUrl) removeImageFiles(IMAGES_DIR, existing.imageUrl); + + await db + .update(medications) + .set({ imageUrl: filename, updatedAt: new Date() }) + .where(and(eq(medications.id, idNum), eq(medications.userId, userId))); + + return { success: true, imageUrl: filename }; } - - let filename: string; - try { - ({ filename } = await writeOptimizedImageSet(IMAGES_DIR, `med-${idNum}`, uploadBuffer)); - } catch { - return reply.status(400).send({ error: "Invalid image", code: "INVALID_IMAGE" }); - } - - // Delete old image if exists - if (existing.imageUrl) removeImageFiles(IMAGES_DIR, existing.imageUrl); - - await db - .update(medications) - .set({ imageUrl: filename, updatedAt: new Date() }) - .where(and(eq(medications.id, idNum), eq(medications.userId, userId))); - - return { success: true, imageUrl: filename }; - }); + ); // Delete medication image - app.delete<{ Params: { id: string } }>("/medications/:id/image", async (req, reply) => { - const idNum = Number(req.params.id); - if (Number.isNaN(idNum)) return reply.badRequest("Invalid id"); + app.delete<{ Params: { id: string } }>( + "/medications/:id/image", + { + schema: { + params: idParamsSchema, + response: { + 204: { type: "null" }, + 400: genericErrorSchema, + 401: genericErrorSchema, + 404: genericErrorSchema, + }, + }, + }, + async (req, reply) => { + const idNum = Number(req.params.id); + if (Number.isNaN(idNum)) return reply.badRequest("Invalid id"); - const userId = await getUserId(req, reply); - const [existing] = await db - .select() - .from(medications) - .where(and(eq(medications.id, idNum), eq(medications.userId, userId))); - if (!existing) return reply.notFound(); + const userId = await getUserId(req, reply); + const [existing] = await db + .select() + .from(medications) + .where(and(eq(medications.id, idNum), eq(medications.userId, userId))); + if (!existing) return reply.notFound(); - if (existing.imageUrl) removeImageFiles(IMAGES_DIR, existing.imageUrl); + if (existing.imageUrl) removeImageFiles(IMAGES_DIR, existing.imageUrl); - await db - .update(medications) - .set({ imageUrl: null, updatedAt: new Date() }) - .where(and(eq(medications.id, idNum), eq(medications.userId, userId))); - return reply.status(204).send(); - }); - - app.post("/medications/usage", async (req, reply) => { - const schema = z.object({ - startDate: z.string().datetime(), - endDate: z.string().datetime(), - includeUntilStart: z.boolean().optional().default(false), - }); - const parsed = schema.safeParse(req.body); - if (!parsed.success) return reply.status(400).send(parsed.error.format()); - const { startDate, endDate, includeUntilStart } = parsed.data; - const start = new Date(startDate); - const end = new Date(endDate); - if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || end <= start) { - return reply.badRequest("Invalid date range"); + await db + .update(medications) + .set({ imageUrl: null, updatedAt: new Date() }) + .where(and(eq(medications.id, idNum), eq(medications.userId, userId))); + return reply.status(204).send(); } + ); - const userId = await getUserId(req, reply); - const rows = await db - .select() - .from(medications) - .where(and(eq(medications.userId, userId), eq(medications.isObsolete, false))) - .orderBy(medications.id); - - const [settingsRow] = await db - .select({ stockCalculationMode: userSettings.stockCalculationMode }) - .from(userSettings) - .where(eq(userSettings.userId, userId)); - const stockCalculationMode = settingsRow?.stockCalculationMode === "manual" ? "manual" : "automatic"; - - // Get all taken doses for this user to calculate actual consumption - const takenDoses = await db - .select() - .from(doseTracking) - .where(and(eq(doseTracking.userId, userId), eq(doseTracking.dismissed, false))); - - const takenDoseIdsByMed = new Map>(); - const takenDoseTimestamps = new Map(); - takenDoses.forEach((dose) => { - const parts = dose.doseId.split("-"); - if (parts.length < 3) return; - const medId = parseInt(parts[0], 10); - if (Number.isNaN(medId)) return; - - if (!takenDoseIdsByMed.has(medId)) { - takenDoseIdsByMed.set(medId, new Set()); + app.post( + "/medications/usage", + { + schema: { + body: usageRequestSchema, + response: { + 200: { type: "array", items: usageItemSchema }, + 400: { anyOf: [genericErrorSchema, validationErrorSchema] }, + 401: genericErrorSchema, + }, + }, + }, + async (req, reply) => { + const schema = z.object({ + startDate: z.string().datetime(), + endDate: z.string().datetime(), + includeUntilStart: z.boolean().optional().default(false), + }); + const parsed = schema.safeParse(req.body); + if (!parsed.success) return reply.status(400).send(parsed.error.format()); + const { startDate, endDate, includeUntilStart } = parsed.data; + const start = new Date(startDate); + const end = new Date(endDate); + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || end <= start) { + return reply.badRequest("Invalid date range"); } - takenDoseIdsByMed.get(medId)!.add(dose.doseId); - const rawTakenAt = Number(dose.takenAt); - let takenAtMs: number; - if (Number.isFinite(rawTakenAt)) { - takenAtMs = rawTakenAt < 1_000_000_000_000 ? rawTakenAt * 1000 : rawTakenAt; - } else { - takenAtMs = new Date(dose.takenAt).getTime(); - } - takenDoseTimestamps.set(dose.doseId, takenAtMs); - }); - // Use current time as the reference point for "available" stock - const now = new Date(); + const userId = await getUserId(req, reply); + const rows = await db + .select() + .from(medications) + .where(and(eq(medications.userId, userId), eq(medications.isObsolete, false))) + .orderBy(medications.id); - const payload = rows.map((row) => { - // Parse intakes from new format, falling back to legacy - const intakes = parseIntakesWithUnits( - row.intakesJson, - { usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson }, - row.intakeRemindersEnabled ?? false - ); - const medForm = row.medicationForm ?? "tablet"; - const blisters = intakes.map((i) => ({ - usage: normalizeIntakeUsageForStock(i, medForm, row.packageType), - every: i.every, - start: i.start, - })); - const pillsPerBlister = row.pillsPerBlister ?? 1; - const packCount = row.packCount ?? 1; - const blistersPerPack = row.blistersPerPack ?? 1; - const looseTablets = row.looseTablets ?? 0; - const stockAdjustment = row.stockAdjustment ?? 0; - const packageType = normalizePackageType(row.packageType); + const [settingsRow] = await db + .select({ stockCalculationMode: userSettings.stockCalculationMode }) + .from(userSettings) + .where(eq(userSettings.userId, userId)); + const stockCalculationMode = settingsRow?.stockCalculationMode === "manual" ? "manual" : "automatic"; - // For bottle type, looseTablets IS the current stock (no blister math) - const isTopical = medForm === "topical" || isTubePackageType(packageType); - const originalTotalPills = isAmountBasedPackageType(packageType) - ? looseTablets + stockAdjustment - : packCount * blistersPerPack * pillsPerBlister + looseTablets + stockAdjustment; + // Get all taken doses for this user to calculate actual consumption + const takenDoses = await db + .select() + .from(doseTracking) + .where(and(eq(doseTracking.userId, userId), eq(doseTracking.dismissed, false))); - // Calculate consumption with the same automatic/manual behavior as frontend coverage. - const stockCorrectionCutoff = row.lastStockCorrectionAt ? new Date(row.lastStockCorrectionAt).getTime() : 0; - const takenDoseIds = takenDoseIdsByMed.get(row.id) ?? new Set(); + const takenDoseIdsByMed = new Map>(); + const takenDoseTimestamps = new Map(); + takenDoses.forEach((dose) => { + const parts = dose.doseId.split("-"); + if (parts.length < 3) return; + const medId = parseInt(parts[0], 10); + if (Number.isNaN(medId)) return; - // Count consumed pills by generating expected doses and checking if they're taken - let consumedUntilNow = 0; - const msPerDay = 86400000; + if (!takenDoseIdsByMed.has(medId)) { + takenDoseIdsByMed.set(medId, new Set()); + } + takenDoseIdsByMed.get(medId)!.add(dose.doseId); + const rawTakenAt = Number(dose.takenAt); + let takenAtMs: number; + if (Number.isFinite(rawTakenAt)) { + takenAtMs = rawTakenAt < 1_000_000_000_000 ? rawTakenAt * 1000 : rawTakenAt; + } else { + takenAtMs = new Date(dose.takenAt).getTime(); + } + takenDoseTimestamps.set(dose.doseId, takenAtMs); + }); - if (isTopical) { - consumedUntilNow = 0; - } else if (stockCalculationMode === "automatic") { - blisters.forEach((blister, blisterIdx) => { - const blisterStart = parseLocalDateTime(blister.start).getTime(); - if (Number.isNaN(blisterStart)) return; + // Use current time as the reference point for "available" stock + const now = new Date(); - const period = Math.max(1, blister.every) * msPerDay; + const payload = rows.map((row) => { + // Parse intakes from new format, falling back to legacy + const intakes = parseIntakesWithUnits( + row.intakesJson, + { usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson }, + row.intakeRemindersEnabled ?? false + ); + const medForm = row.medicationForm ?? "tablet"; + const blisters = intakes.map((i) => ({ + usage: normalizeIntakeUsageForStock(i, medForm, row.packageType), + every: i.every, + start: i.start, + })); + const pillsPerBlister = row.pillsPerBlister ?? 1; + const packCount = row.packCount ?? 1; + const blistersPerPack = row.blistersPerPack ?? 1; + const looseTablets = row.looseTablets ?? 0; + const stockAdjustment = row.stockAdjustment ?? 0; + const packageType = normalizePackageType(row.packageType); - let effectiveStart: number; - if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) { - const elapsedSinceStart = stockCorrectionCutoff - blisterStart; - const periodsElapsed = Math.floor(elapsedSinceStart / period); - effectiveStart = blisterStart + (periodsElapsed + 1) * period; - } else { - effectiveStart = blisterStart; - } + // For bottle type, looseTablets IS the current stock (no blister math) + const isTopical = medForm === "topical" || isTubePackageType(packageType); + const originalTotalPills = isAmountBasedPackageType(packageType) + ? looseTablets + stockAdjustment + : packCount * blistersPerPack * pillsPerBlister + looseTablets + stockAdjustment; - const intake = intakes[blisterIdx]; - const intakePerson = intake?.takenBy; - const fallbackPeople = parseTakenByJson(row.takenByJson); - let peopleForThisIntake: Array; - if (intakePerson) { - peopleForThisIntake = [intakePerson]; - } else if (fallbackPeople.length > 0) { - peopleForThisIntake = fallbackPeople; - } else { - peopleForThisIntake = [null]; - } + // Calculate consumption with the same automatic/manual behavior as frontend coverage. + const stockCorrectionCutoff = row.lastStockCorrectionAt ? new Date(row.lastStockCorrectionAt).getTime() : 0; + const takenDoseIds = takenDoseIdsByMed.get(row.id) ?? new Set(); - let timeBasedConsumed = 0; - let lastAutoConsumedDateMs = 0; + // Count consumed pills by generating expected doses and checking if they're taken + let consumedUntilNow = 0; + const msPerDay = 86400000; - if (effectiveStart <= now.getTime()) { - const occurrences = Math.floor((now.getTime() - effectiveStart) / period) + 1; - timeBasedConsumed = occurrences * blister.usage * peopleForThisIntake.length; + if (isTopical) { + consumedUntilNow = 0; + } else if (stockCalculationMode === "automatic") { + blisters.forEach((blister, blisterIdx) => { + const blisterStart = parseLocalDateTime(blister.start).getTime(); + if (Number.isNaN(blisterStart)) return; - const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period); - lastAutoConsumedDateMs = new Date( - lastDoseTime.getFullYear(), - lastDoseTime.getMonth(), - lastDoseTime.getDate() + const period = Math.max(1, blister.every) * msPerDay; + + let effectiveStart: number; + if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) { + const elapsedSinceStart = stockCorrectionCutoff - blisterStart; + const periodsElapsed = Math.floor(elapsedSinceStart / period); + effectiveStart = blisterStart + (periodsElapsed + 1) * period; + } else { + effectiveStart = blisterStart; + } + + const intake = intakes[blisterIdx]; + const intakePerson = intake?.takenBy; + const fallbackPeople = parseTakenByJson(row.takenByJson); + let peopleForThisIntake: Array; + if (intakePerson) { + peopleForThisIntake = [intakePerson]; + } else if (fallbackPeople.length > 0) { + peopleForThisIntake = fallbackPeople; + } else { + peopleForThisIntake = [null]; + } + + let timeBasedConsumed = 0; + let lastAutoConsumedDateMs = 0; + + if (effectiveStart <= now.getTime()) { + const occurrences = Math.floor((now.getTime() - effectiveStart) / period) + 1; + timeBasedConsumed = occurrences * blister.usage * peopleForThisIntake.length; + + const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period); + lastAutoConsumedDateMs = new Date( + lastDoseTime.getFullYear(), + lastDoseTime.getMonth(), + lastDoseTime.getDate() + ).getTime(); + } + + const stockCorrectionDateOnly = + stockCorrectionCutoff > 0 + ? new Date( + new Date(stockCorrectionCutoff).getFullYear(), + new Date(stockCorrectionCutoff).getMonth(), + new Date(stockCorrectionCutoff).getDate() + ).getTime() + : 0; + const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly); + + let earlyTakenConsumed = 0; + for (const doseId of takenDoseIds) { + const parts = doseId.split("-"); + if (parts.length < 3) continue; + const bIdx = parseInt(parts[1], 10); + const timestamp = parseInt(parts[2], 10); + if (!Number.isNaN(bIdx) && !Number.isNaN(timestamp) && bIdx === blisterIdx && timestamp > earlyCutoff) { + earlyTakenConsumed += blister.usage; + } + } + + consumedUntilNow += timeBasedConsumed + earlyTakenConsumed; + }); + } else { + blisters.forEach((blister, blisterIdx) => { + const blisterStart = parseLocalDateTime(blister.start); + const blisterStartDateOnly = new Date( + blisterStart.getFullYear(), + blisterStart.getMonth(), + blisterStart.getDate() ).getTime(); - } + if (Number.isNaN(blisterStartDateOnly)) return; - const stockCorrectionDateOnly = - stockCorrectionCutoff > 0 - ? new Date( - new Date(stockCorrectionCutoff).getFullYear(), - new Date(stockCorrectionCutoff).getMonth(), - new Date(stockCorrectionCutoff).getDate() - ).getTime() - : 0; - const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly); + for (const doseId of takenDoseIds) { + const parts = doseId.split("-"); + if (parts.length < 3) continue; - let earlyTakenConsumed = 0; - for (const doseId of takenDoseIds) { - const parts = doseId.split("-"); - if (parts.length < 3) continue; - const bIdx = parseInt(parts[1], 10); - const timestamp = parseInt(parts[2], 10); - if (!Number.isNaN(bIdx) && !Number.isNaN(timestamp) && bIdx === blisterIdx && timestamp > earlyCutoff) { - earlyTakenConsumed += blister.usage; + const parsedBlisterIdx = parseInt(parts[1], 10); + const doseTimestamp = parseInt(parts[2], 10); + if (Number.isNaN(parsedBlisterIdx) || Number.isNaN(doseTimestamp) || parsedBlisterIdx !== blisterIdx) { + continue; + } + + const takenAt = takenDoseTimestamps.get(doseId) ?? 0; + const afterCorrectionOrNoCorrection = stockCorrectionCutoff === 0 || takenAt > stockCorrectionCutoff; + + if (doseTimestamp >= blisterStartDateOnly && afterCorrectionOrNoCorrection) { + consumedUntilNow += blister.usage; + } } - } + }); + } - consumedUntilNow += timeBasedConsumed + earlyTakenConsumed; - }); - } else { - blisters.forEach((blister, blisterIdx) => { - const blisterStart = parseLocalDateTime(blister.start); - const blisterStartDateOnly = new Date( - blisterStart.getFullYear(), - blisterStart.getMonth(), - blisterStart.getDate() - ).getTime(); - if (Number.isNaN(blisterStartDateOnly)) return; + const currentStock = isTopical ? originalTotalPills : Math.max(0, originalTotalPills - consumedUntilNow); - for (const doseId of takenDoseIds) { - const parts = doseId.split("-"); - if (parts.length < 3) continue; + // Calculate usage for the planning period + // Always use the user-selected start date for the usage calculation. + // Using max(now, start) would cause asymmetric counting when now falls + // between morning and evening doses on the start day (e.g., morning dose + // skipped but evening counted), leading to confusing off-by-one results. + // The stock already reflects consumed doses, so no double-counting occurs. + // When includeUntilStart is true, calculate from now to end (useful for trip planning) + const effectivePlannerStart = includeUntilStart ? now : start; + const usageTotal = isTopical ? 0 : calculateUsageInRange(blisters, effectivePlannerStart, end); - const parsedBlisterIdx = parseInt(parts[1], 10); - const doseTimestamp = parseInt(parts[2], 10); - if (Number.isNaN(parsedBlisterIdx) || Number.isNaN(doseTimestamp) || parsedBlisterIdx !== blisterIdx) { - continue; - } + const blistersNeeded = pillsPerBlister > 0 ? Math.ceil(usageTotal / pillsPerBlister) : 0; - const takenAt = takenDoseTimestamps.get(doseId) ?? 0; - const afterCorrectionOrNoCorrection = stockCorrectionCutoff === 0 || takenAt > stockCorrectionCutoff; + // Calculate AVAILABLE = stock AFTER the planned period (currentStock - usageTotal) + const availableAfterPeriod = Math.max(0, currentStock - usageTotal); - if (doseTimestamp >= blisterStartDateOnly && afterCorrectionOrNoCorrection) { - consumedUntilNow += blister.usage; - } - } - }); - } + let fullBlisters: number; + let loosePills: number; - const currentStock = isTopical ? originalTotalPills : Math.max(0, originalTotalPills - consumedUntilNow); + if (isAmountBasedPackageType(packageType)) { + // Bottle type: no blisters, everything is loose pills + fullBlisters = 0; + loosePills = availableAfterPeriod; + } else { + // Blister type: calculate stock breakdown + // Consumption order: loose pills first, then from blisters + const totalConsumedByEnd = originalTotalPills - availableAfterPeriod; + const looseConsumedByEnd = Math.min(totalConsumedByEnd, looseTablets); + const loosePillsRemaining = Math.max(0, looseTablets - looseConsumedByEnd); + const blisterPillsConsumed = totalConsumedByEnd - looseConsumedByEnd; + const originalBlisterPills = originalTotalPills - looseTablets; + const blisterPillsRemaining = Math.max(0, originalBlisterPills - blisterPillsConsumed); - // Calculate usage for the planning period - // Always use the user-selected start date for the usage calculation. - // Using max(now, start) would cause asymmetric counting when now falls - // between morning and evening doses on the start day (e.g., morning dose - // skipped but evening counted), leading to confusing off-by-one results. - // The stock already reflects consumed doses, so no double-counting occurs. - // When includeUntilStart is true, calculate from now to end (useful for trip planning) - const effectivePlannerStart = includeUntilStart ? now : start; - const usageTotal = isTopical ? 0 : calculateUsageInRange(blisters, effectivePlannerStart, end); + fullBlisters = pillsPerBlister > 0 ? Math.floor(blisterPillsRemaining / pillsPerBlister) : 0; + const openBlisterPills = pillsPerBlister > 0 ? blisterPillsRemaining % pillsPerBlister : 0; + loosePills = loosePillsRemaining + openBlisterPills; // Combine open blister + remaining loose + } - const blistersNeeded = pillsPerBlister > 0 ? Math.ceil(usageTotal / pillsPerBlister) : 0; + const enough = currentStock >= usageTotal; + return { + medicationId: row.id, + medicationName: row.name, + totalPills: currentStock, + currentPills: currentStock, + plannerUsage: usageTotal, + blisterSize: pillsPerBlister, + blistersNeeded, + fullBlisters, + loosePills, + enough, + packageType, + }; + }); - // Calculate AVAILABLE = stock AFTER the planned period (currentStock - usageTotal) - const availableAfterPeriod = Math.max(0, currentStock - usageTotal); - - let fullBlisters: number; - let loosePills: number; - - if (isAmountBasedPackageType(packageType)) { - // Bottle type: no blisters, everything is loose pills - fullBlisters = 0; - loosePills = availableAfterPeriod; - } else { - // Blister type: calculate stock breakdown - // Consumption order: loose pills first, then from blisters - const totalConsumedByEnd = originalTotalPills - availableAfterPeriod; - const looseConsumedByEnd = Math.min(totalConsumedByEnd, looseTablets); - const loosePillsRemaining = Math.max(0, looseTablets - looseConsumedByEnd); - const blisterPillsConsumed = totalConsumedByEnd - looseConsumedByEnd; - const originalBlisterPills = originalTotalPills - looseTablets; - const blisterPillsRemaining = Math.max(0, originalBlisterPills - blisterPillsConsumed); - - fullBlisters = pillsPerBlister > 0 ? Math.floor(blisterPillsRemaining / pillsPerBlister) : 0; - const openBlisterPills = pillsPerBlister > 0 ? blisterPillsRemaining % pillsPerBlister : 0; - loosePills = loosePillsRemaining + openBlisterPills; // Combine open blister + remaining loose - } - - const enough = currentStock >= usageTotal; - return { - medicationId: row.id, - medicationName: row.name, - totalPills: currentStock, - currentPills: currentStock, - plannerUsage: usageTotal, - blisterSize: pillsPerBlister, - blistersNeeded, - fullBlisters, - loosePills, - enough, - packageType, - }; - }); - - return payload; - }); + return payload; + } + ); // --------------------------------------------------------------------------- // POST /medications/dismiss-until - Set dismissedUntil date for multiple medications @@ -1287,48 +1691,80 @@ export async function medicationRoutes(app: FastifyInstance) { until: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in YYYY-MM-DD format"), }); - app.post<{ Body: z.infer }>("/medications/dismiss-until", async (req, reply) => { - const parsed = dismissUntilSchema.safeParse(req.body); - if (!parsed.success) { - return reply.status(400).send({ error: parsed.error.errors[0]?.message ?? "Invalid input" }); - } - - const userId = await getUserId(req, reply); - const { medicationIds, until } = parsed.data; - - // Update dismissedUntil for all specified medications owned by this user - let updatedCount = 0; - for (const medId of medicationIds) { - const result = await db - .update(medications) - .set({ dismissedUntil: until }) - .where(and(eq(medications.id, medId), eq(medications.userId, userId))); - if (result.rowsAffected > 0) { - updatedCount++; + app.post<{ Body: z.infer }>( + "/medications/dismiss-until", + { + schema: { + body: dismissUntilBodySchema, + response: { + 200: { + type: "object", + properties: { + success: { type: "boolean" }, + updatedCount: { type: "integer" }, + }, + }, + 400: { anyOf: [genericErrorSchema, validationErrorSchema] }, + 401: genericErrorSchema, + }, + }, + }, + async (req, reply) => { + const parsed = dismissUntilSchema.safeParse(req.body); + if (!parsed.success) { + return reply.status(400).send({ error: parsed.error.errors[0]?.message ?? "Invalid input" }); } - } - return { success: true, updatedCount }; - }); + const userId = await getUserId(req, reply); + const { medicationIds, until } = parsed.data; + + // Update dismissedUntil for all specified medications owned by this user + let updatedCount = 0; + for (const medId of medicationIds) { + const result = await db + .update(medications) + .set({ dismissedUntil: until }) + .where(and(eq(medications.id, medId), eq(medications.userId, userId))); + if (result.rowsAffected > 0) { + updatedCount++; + } + } + + return { success: true, updatedCount }; + } + ); // --------------------------------------------------------------------------- // DELETE /medications/:id/dismiss-until - Clear dismissedUntil for a medication // --------------------------------------------------------------------------- - app.delete<{ Params: { id: string } }>("/medications/:id/dismiss-until", async (req, reply) => { - const medId = parseInt(req.params.id, 10); - if (Number.isNaN(medId)) { - return reply.status(400).send({ error: "Invalid medication ID" }); + app.delete<{ Params: { id: string } }>( + "/medications/:id/dismiss-until", + { + schema: { + params: idParamsSchema, + response: { + 200: successResponseSchema, + 400: genericErrorSchema, + 401: genericErrorSchema, + }, + }, + }, + async (req, reply) => { + const medId = parseInt(req.params.id, 10); + if (Number.isNaN(medId)) { + return reply.status(400).send({ error: "Invalid medication ID" }); + } + + const userId = await getUserId(req, reply); + + await db + .update(medications) + .set({ dismissedUntil: null }) + .where(and(eq(medications.id, medId), eq(medications.userId, userId))); + + return { success: true }; } - - const userId = await getUserId(req, reply); - - await db - .update(medications) - .set({ dismissedUntil: null }) - .where(and(eq(medications.id, medId), eq(medications.userId, userId))); - - return { success: true }; - }); + ); } function calculateUsageInRange( diff --git a/backend/src/routes/oidc.ts b/backend/src/routes/oidc.ts index 0751c7f..f84faa3 100644 --- a/backend/src/routes/oidc.ts +++ b/backend/src/routes/oidc.ts @@ -5,7 +5,7 @@ import * as client from "openid-client"; import { db } from "../db/client.js"; import { refreshTokens, users } from "../db/schema.js"; import { env } from "../plugins/env.js"; -import { applyOpenApiRouteStandards } from "../utils/openapi-route-standards.js"; +import { applyOpenApiRouteStandards, genericErrorSchema } from "../utils/openapi-route-standards.js"; // ============================================================================= // OIDC Configuration Cache @@ -54,10 +54,10 @@ export async function oidcRoutes(app: FastifyInstance) { if (!env.OIDC_ENABLED) { // Register a disabled route that returns an error - app.get("/auth/oidc/login", async (_request, reply) => { + app.get("/auth/oidc/login", { schema: { response: { 400: genericErrorSchema } } }, async (_request, reply) => { return reply.status(400).send({ error: "OIDC authentication is not enabled" }); }); - app.get("/auth/oidc/callback", async (_request, reply) => { + app.get("/auth/oidc/callback", { schema: { response: { 400: genericErrorSchema } } }, async (_request, reply) => { return reply.status(400).send({ error: "OIDC authentication is not enabled" }); }); return; @@ -66,58 +66,85 @@ export async function oidcRoutes(app: FastifyInstance) { // --------------------------------------------------------------------------- // GET /auth/oidc/login - Initiates OIDC flow // --------------------------------------------------------------------------- - app.get("/auth/oidc/login", async (request, reply) => { - try { - const config = await getOIDCConfig(); + app.get( + "/auth/oidc/login", + { + schema: { + response: { + 302: { type: "null", description: "Redirect to OIDC provider" }, + 500: genericErrorSchema, + }, + }, + }, + async (request, reply) => { + try { + const config = await getOIDCConfig(); - // Generate PKCE values - const codeVerifier = generateCodeVerifier(); - const codeChallenge = generateCodeChallenge(codeVerifier); - const state = generateState(); + // Generate PKCE values + const codeVerifier = generateCodeVerifier(); + const codeChallenge = generateCodeChallenge(codeVerifier); + const state = generateState(); - // Store PKCE verifier and state in signed cookies (short-lived) - reply.setCookie("oidc_code_verifier", codeVerifier, { - httpOnly: true, - secure: env.NODE_ENV === "production", - sameSite: "lax", - path: "/", - maxAge: 600, // 10 minutes - signed: true, - }); + // Store PKCE verifier and state in signed cookies (short-lived) + reply.setCookie("oidc_code_verifier", codeVerifier, { + httpOnly: true, + secure: env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: 600, // 10 minutes + signed: true, + }); - reply.setCookie("oidc_state", state, { - httpOnly: true, - secure: env.NODE_ENV === "production", - sameSite: "lax", - path: "/", - maxAge: 600, - signed: true, - }); + reply.setCookie("oidc_state", state, { + httpOnly: true, + secure: env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: 600, + signed: true, + }); - // Build authorization URL - const redirectUri = env.OIDC_REDIRECT_URI!; - const scope = env.OIDC_SCOPES; + // Build authorization URL + const redirectUri = env.OIDC_REDIRECT_URI!; + const scope = env.OIDC_SCOPES; - const authUrl = client.buildAuthorizationUrl(config, { - redirect_uri: redirectUri, - scope, - state, - code_challenge: codeChallenge, - code_challenge_method: "S256", - }); + const authUrl = client.buildAuthorizationUrl(config, { + redirect_uri: redirectUri, + scope, + state, + code_challenge: codeChallenge, + code_challenge_method: "S256", + }); - return reply.redirect(authUrl.href); - } catch (err: unknown) { - request.log.error({ err }, "[OIDC] Login initialization failed"); - return reply.redirect(`${getFrontendUrl()}/?error=oidc_init_failed`); + return reply.redirect(authUrl.href); + } catch (err: unknown) { + request.log.error({ err }, "[OIDC] Login initialization failed"); + return reply.redirect(`${getFrontendUrl()}/?error=oidc_init_failed`); + } } - }); + ); // --------------------------------------------------------------------------- // GET /auth/oidc/callback - Handles callback from OIDC provider // --------------------------------------------------------------------------- app.get<{ Querystring: { code?: string; state?: string; error?: string; error_description?: string } }>( "/auth/oidc/callback", + { + schema: { + querystring: { + type: "object", + properties: { + code: { type: "string" }, + state: { type: "string" }, + error: { type: "string" }, + error_description: { type: "string" }, + }, + }, + response: { + 302: { type: "null", description: "Redirect back to frontend" }, + }, + }, + }, async (request, reply) => { const { code, state, error, error_description } = request.query; diff --git a/backend/src/routes/planner.ts b/backend/src/routes/planner.ts index 4d7dab7..b36f553 100644 --- a/backend/src/routes/planner.ts +++ b/backend/src/routes/planner.ts @@ -15,7 +15,11 @@ import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; import { env } from "../plugins/env.js"; import { updateReminderSentTime, updateUserReminderSentTime } from "../services/reminder-scheduler.js"; import type { AuthUser } from "../types/fastify.js"; -import { applyOpenApiRouteStandards } from "../utils/openapi-route-standards.js"; +import { + applyOpenApiRouteStandards, + genericErrorSchema, + validationErrorSchema, +} from "../utils/openapi-route-standards.js"; import { getPlannerUnitKind, isAmountBasedPackageType, @@ -132,6 +136,64 @@ type PrescriptionReminderBody = { language?: Language; }; +const plannerRowSchema = { + type: "object", + required: [ + "medicationId", + "medicationName", + "totalPills", + "plannerUsage", + "blisterSize", + "blistersNeeded", + "fullBlisters", + "loosePills", + "enough", + ], + properties: { + medicationId: { type: "integer" }, + medicationName: { type: "string" }, + totalPills: { type: "number" }, + plannerUsage: { type: "number" }, + blisterSize: { type: "number" }, + blistersNeeded: { type: "number" }, + fullBlisters: { type: "number" }, + loosePills: { type: "number" }, + enough: { type: "boolean" }, + packageType: { type: "string" }, + }, +} as const; + +const lowStockItemSchema = { + type: "object", + required: ["name", "medsLeft"], + properties: { + name: { type: "string" }, + medsLeft: { type: "number" }, + daysLeft: { type: "number" }, + depletionDate: { type: "string" }, + isCritical: { type: "boolean" }, + }, +} as const; + +const prescriptionReminderItemSchema = { + type: "object", + required: ["name", "remainingRefills", "threshold"], + properties: { + name: { type: "string" }, + remainingRefills: { type: "integer" }, + threshold: { type: "integer" }, + expiryDate: { type: "string" }, + }, +} as const; + +const notificationResponseSchema = { + type: "object", + properties: { + success: { type: "boolean" }, + message: { type: "string" }, + }, +} as const; + export async function plannerRoutes(app: FastifyInstance) { // Add auth hook for all planner routes app.addHook("preHandler", requireAuth); @@ -150,89 +212,131 @@ export async function plannerRoutes(app: FastifyInstance) { } // Demand calculator notification (supports email and push) - app.post<{ Body: SendEmailBody }>("/planner/send-email", async (request, reply) => { - const { email, from, until, rows, language: bodyLanguage } = request.body; - request.log.info( - { hasEmail: Boolean(email), rowCount: rows?.length ?? 0 }, - "[Planner] Demand notification request received" - ); - - if (!rows || rows.length === 0) { - return reply.status(400).send({ error: "Missing planner data" }); - } - - // Load user settings for notification channels - const userId = await getUserId(request); - const activeMeds = await db - .select({ id: medications.id }) - .from(medications) - .where(and(eq(medications.userId, userId), eq(medications.isObsolete, false))); - const activeMedIds = new Set(activeMeds.map((med) => med.id)); - const activeRows = rows.filter((row) => activeMedIds.has(row.medicationId)); - if (activeRows.length === 0) { - request.log.warn("[Planner] Demand notification skipped: no active medications in request"); - return reply.status(400).send({ error: "No active medications to notify" }); - } - - const userSettings = await loadUserSettings(userId); - const notificationSettings = { - emailEnabled: userSettings.emailEnabled, - shoutrrrEnabled: userSettings.shoutrrrEnabled, - shoutrrrUrl: userSettings.shoutrrrUrl || "", - }; - request.log.info( - { - userId, - emailEnabled: notificationSettings.emailEnabled, - pushEnabled: notificationSettings.shoutrrrEnabled, - hasPushUrl: Boolean(notificationSettings.shoutrrrUrl), - activeRowCount: activeRows.length, + app.post<{ Body: SendEmailBody }>( + "/planner/send-email", + { + schema: { + body: { + type: "object", + properties: { + email: { type: "string" }, + from: { type: "string" }, + until: { type: "string" }, + language: { type: "string" }, + rows: { type: "array", items: plannerRowSchema }, + }, + example: { + email: "daniel@example.com", + from: "2026-03-11", + until: "2026-04-11", + language: "en", + rows: [ + { + medicationId: 1, + medicationName: "Ibuprofen 400", + totalPills: 20, + plannerUsage: 12, + blisterSize: 10, + blistersNeeded: 2, + fullBlisters: 1, + loosePills: 8, + enough: true, + packageType: "box", + }, + ], + }, + }, + response: { + 200: notificationResponseSchema, + 400: { anyOf: [genericErrorSchema, validationErrorSchema] }, + 401: genericErrorSchema, + 500: genericErrorSchema, + }, }, - "[Planner] Demand notification channel state" - ); + }, + async (request, reply) => { + const { email, from, until, rows, language: bodyLanguage } = request.body; + request.log.info( + { hasEmail: Boolean(email), rowCount: rows?.length ?? 0 }, + "[Planner] Demand notification request received" + ); - // Get locale from user settings or use the language passed in the body - const language: Language = (userSettings.language as Language) || bodyLanguage || "en"; - const locale = getDateLocale(language); - const tr = getTranslations(language); - const dc = tr.demandCalculator; + if (!rows || rows.length === 0) { + return reply.status(400).send({ error: "Missing planner data" }); + } - // Format dates for display - escape to prevent XSS even though toLocaleDateString should be safe - const fromDate = escapeHtml( - new Date(from).toLocaleDateString(locale, { - year: "numeric", - month: "long", - day: "numeric", - }) - ); - const untilDate = escapeHtml( - new Date(until).toLocaleDateString(locale, { - year: "numeric", - month: "long", - day: "numeric", - }) - ); + // Load user settings for notification channels + const userId = await getUserId(request); + const activeMeds = await db + .select({ id: medications.id }) + .from(medications) + .where(and(eq(medications.userId, userId), eq(medications.isObsolete, false))); + const activeMedIds = new Set(activeMeds.map((med) => med.id)); + const activeRows = rows.filter((row) => activeMedIds.has(row.medicationId)); + if (activeRows.length === 0) { + request.log.warn("[Planner] Demand notification skipped: no active medications in request"); + return reply.status(400).send({ error: "No active medications to notify" }); + } - const outOfStockCount = activeRows.filter((r) => !r.enough).length; - const summaryText = outOfStockCount > 0 ? t(dc.summaryOutOfStock, { count: outOfStockCount }) : dc.summaryAllOk; + const userSettings = await loadUserSettings(userId); + const notificationSettings = { + emailEnabled: userSettings.emailEnabled, + shoutrrrEnabled: userSettings.shoutrrrEnabled, + shoutrrrUrl: userSettings.shoutrrrUrl || "", + }; + request.log.info( + { + userId, + emailEnabled: notificationSettings.emailEnabled, + pushEnabled: notificationSettings.shoutrrrEnabled, + hasPushUrl: Boolean(notificationSettings.shoutrrrUrl), + activeRowCount: activeRows.length, + }, + "[Planner] Demand notification channel state" + ); - // Load prescription data for medications referenced in planner rows - const medIds = activeRows.map((r) => r.medicationId).filter(Boolean); - const allMeds = - medIds.length > 0 - ? await db - .select({ - id: medications.id, - prescriptionEnabled: medications.prescriptionEnabled, - prescriptionRemainingRefills: medications.prescriptionRemainingRefills, - }) - .from(medications) - .where(eq(medications.userId, userId)) - : []; - const prescriptionMap = new Map(allMeds.map((m) => [m.id, m])); + // Get locale from user settings or use the language passed in the body + const language: Language = (userSettings.language as Language) || bodyLanguage || "en"; + const locale = getDateLocale(language); + const tr = getTranslations(language); + const dc = tr.demandCalculator; - // Build plain text (shared between email and push) - const plainText = `${dc.title} + // Format dates for display - escape to prevent XSS even though toLocaleDateString should be safe + const fromDate = escapeHtml( + new Date(from).toLocaleDateString(locale, { + year: "numeric", + month: "long", + day: "numeric", + }) + ); + const untilDate = escapeHtml( + new Date(until).toLocaleDateString(locale, { + year: "numeric", + month: "long", + day: "numeric", + }) + ); + + const outOfStockCount = activeRows.filter((r) => !r.enough).length; + const summaryText = outOfStockCount > 0 ? t(dc.summaryOutOfStock, { count: outOfStockCount }) : dc.summaryAllOk; + + // Load prescription data for medications referenced in planner rows + const medIds = activeRows.map((r) => r.medicationId).filter(Boolean); + const allMeds = + medIds.length > 0 + ? await db + .select({ + id: medications.id, + prescriptionEnabled: medications.prescriptionEnabled, + prescriptionRemainingRefills: medications.prescriptionRemainingRefills, + }) + .from(medications) + .where(eq(medications.userId, userId)) + : []; + const prescriptionMap = new Map(allMeds.map((m) => [m.id, m])); + + // Build plain text (shared between email and push) + const plainText = `${dc.title} ${t(dc.description, { from: fromDate, until: untilDate })} ${summaryText} @@ -260,67 +364,67 @@ ${activeRows --- ${getFooterPlain(language)}`; - const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] }; + const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] }; - // Send email if enabled - if (notificationSettings.emailEnabled && email) { - const smtpHost = process.env.SMTP_HOST; - const smtpUser = process.env.SMTP_USER; - const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence - const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10); - const smtpSecure = process.env.SMTP_SECURE === "true"; - const smtpFrom = process.env.SMTP_FROM ?? smtpUser; + // Send email if enabled + if (notificationSettings.emailEnabled && email) { + const smtpHost = process.env.SMTP_HOST; + const smtpUser = process.env.SMTP_USER; + const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence + const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10); + const smtpSecure = process.env.SMTP_SECURE === "true"; + const smtpFrom = process.env.SMTP_FROM ?? smtpUser; - request.log.info( - { - hasSmtpHost: Boolean(smtpHost), - hasSmtpUser: Boolean(smtpUser), - hasSmtpPass: Boolean(smtpPass), - smtpPort, - smtpSecure, - hasSmtpFrom: Boolean(smtpFrom), - to: maskEmail(email), - }, - "[Planner] Demand email path selected" - ); + request.log.info( + { + hasSmtpHost: Boolean(smtpHost), + hasSmtpUser: Boolean(smtpUser), + hasSmtpPass: Boolean(smtpPass), + smtpPort, + smtpSecure, + hasSmtpFrom: Boolean(smtpFrom), + to: maskEmail(email), + }, + "[Planner] Demand email path selected" + ); - if (smtpHost && smtpUser) { - // Build HTML table with horizontal scroll for mobile - // Escape/coerce all user-provided values to prevent XSS - const tableRows = activeRows - .map((row) => { - const safeName = escapeHtml(row.medicationName); - const safePlannerUsage = Number(row.plannerUsage) || 0; - const safeBlistersNeeded = Number(row.blistersNeeded) || 0; - const safeBlisterSize = Number(row.blisterSize) || 0; - const safeFullBlisters = Number(row.fullBlisters) || 0; - const safeLoosePills = Math.round((Number(row.loosePills) || 0) * 10) / 10; - const isBottle = isContainerPackage(row.packageType); + if (smtpHost && smtpUser) { + // Build HTML table with horizontal scroll for mobile + // Escape/coerce all user-provided values to prevent XSS + const tableRows = activeRows + .map((row) => { + const safeName = escapeHtml(row.medicationName); + const safePlannerUsage = Number(row.plannerUsage) || 0; + const safeBlistersNeeded = Number(row.blistersNeeded) || 0; + const safeBlisterSize = Number(row.blisterSize) || 0; + const safeFullBlisters = Number(row.fullBlisters) || 0; + const safeLoosePills = Math.round((Number(row.loosePills) || 0) * 10) / 10; + const isBottle = isContainerPackage(row.packageType); - // "Blisters needed" column: dash for bottles - const neededCell = isBottle ? "–" : `${safeBlistersNeeded} × ${safeBlisterSize}`; + // "Blisters needed" column: dash for bottles + const neededCell = isBottle ? "–" : `${safeBlistersNeeded} × ${safeBlisterSize}`; - // "Prescription refills" column - const medPrescription = prescriptionMap.get(row.medicationId); - const rxCell = medPrescription?.prescriptionEnabled - ? String(medPrescription.prescriptionRemainingRefills ?? 0) - : dc.prescriptionNotApplicable; + // "Prescription refills" column + const medPrescription = prescriptionMap.get(row.medicationId); + const rxCell = medPrescription?.prescriptionEnabled + ? String(medPrescription.prescriptionRemainingRefills ?? 0) + : dc.prescriptionNotApplicable; - // "Available" column: match frontend format - let availableCell: string; - if (isBottle) { - const availableUnit = getPlannerUnit(row.packageType, tr); - availableCell = `${safeLoosePills} ${availableUnit}`; - } else { - availableCell = `${safeFullBlisters} ${tr.common.blisters}`; - if (safeLoosePills > 0) { - availableCell += ` + ${safeLoosePills} ${tr.common.pills}`; + // "Available" column: match frontend format + let availableCell: string; + if (isBottle) { + const availableUnit = getPlannerUnit(row.packageType, tr); + availableCell = `${safeLoosePills} ${availableUnit}`; + } else { + availableCell = `${safeFullBlisters} ${tr.common.blisters}`; + if (safeLoosePills > 0) { + availableCell += ` + ${safeLoosePills} ${tr.common.pills}`; + } } - } - const rowBg = row.enough ? "" : " background: #fef2f2;"; + const rowBg = row.enough ? "" : " background: #fef2f2;"; - return ` + return ` ${safeName} ${safePlannerUsage} ${getPlannerUnit(row.packageType, tr)} @@ -336,10 +440,10 @@ ${getFooterPlain(language)}`; `; - }) - .join(""); + }) + .join(""); - const html = ` + const html = `

${dc.title}

@@ -379,303 +483,337 @@ ${getFooterPlain(language)}`;
`; - try { - const transporter = nodemailer.createTransport({ - host: smtpHost, - port: smtpPort, - secure: smtpSecure, - auth: { - user: smtpUser, - pass: smtpPass ?? "", - }, - }); + try { + const transporter = nodemailer.createTransport({ + host: smtpHost, + port: smtpPort, + secure: smtpSecure, + auth: { + user: smtpUser, + pass: smtpPass ?? "", + }, + }); - request.log.info({ to: maskEmail(email) }, "[Planner] Sending demand email"); + request.log.info({ to: maskEmail(email) }, "[Planner] Sending demand email"); - const mailResult = await transporter.sendMail({ - from: smtpFrom, - to: email, - subject: t(dc.subject, { from: fromDate, until: untilDate }), - text: plainText, - html, - }); + const mailResult = await transporter.sendMail({ + from: smtpFrom, + to: email, + subject: t(dc.subject, { from: fromDate, until: untilDate }), + text: plainText, + html, + }); - const deliveryError = getDeliveryError(mailResult); - if (deliveryError) { - throw new Error(deliveryError); + const deliveryError = getDeliveryError(mailResult); + if (deliveryError) { + throw new Error(deliveryError); + } + + request.log.info({ to: maskEmail(email), messageId: mailResult.messageId }, "[Planner] Demand email sent"); + results.email = true; + } catch (error) { + request.log.error({ error, to: maskEmail(email) }, "[Planner] Demand email failed"); + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + results.errors.push(`Email: ${errorMessage}`); } - - request.log.info({ to: maskEmail(email), messageId: mailResult.messageId }, "[Planner] Demand email sent"); - results.email = true; - } catch (error) { - request.log.error({ error, to: maskEmail(email) }, "[Planner] Demand email failed"); - const errorMessage = error instanceof Error ? error.message : "Unknown error"; - results.errors.push(`Email: ${errorMessage}`); + } else { + request.log.warn( + { + hasSmtpHost: Boolean(smtpHost), + hasSmtpUser: Boolean(smtpUser), + to: maskEmail(email), + }, + "[Planner] Demand email skipped: SMTP not configured" + ); } } else { - request.log.warn( + request.log.info( + { emailEnabled: notificationSettings.emailEnabled, hasRecipient: Boolean(email) }, + "[Planner] Demand email channel not active" + ); + } + + // Send push notification if enabled + if (notificationSettings.shoutrrrEnabled && notificationSettings.shoutrrrUrl) { + const pushTitle = t(dc.subject, { from: fromDate, until: untilDate }); + const pushMessage = `${summaryText}\n\n${activeRows + .map((r) => { + const usage = `${r.plannerUsage} ${getPlannerUnit(r.packageType, tr)}`; + const status = r.enough ? dc.statusEnough : dc.statusEmpty; + return `${r.enough ? "✓" : "✗"} ${r.medicationName}: ${usage} - ${status}`; + }) + .join("\n")}\n\n---\n${getFooterPlain(language)}`; + + try { + const pushResult = await sendShoutrrrNotification(notificationSettings.shoutrrrUrl, pushTitle, pushMessage); + if (pushResult.success) { + results.push = true; + } else { + results.errors.push(`Push: ${pushResult.error}`); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + results.errors.push(`Push: ${errorMessage}`); + } + } + + // Build response message + const sentChannels: string[] = []; + if (results.email) sentChannels.push("email"); + if (results.push) sentChannels.push("push"); + + if (sentChannels.length > 0) { + return reply.send({ + success: true, + message: `Notification sent via ${sentChannels.join(" and ")}`, + }); + } else if (results.errors.length > 0) { + return reply.status(500).send({ error: results.errors.join("; ") }); + } else { + return reply.status(400).send({ error: "No notification channels configured" }); + } + } + ); + + // Reminder notification for low stock medications (supports email and push) + app.post<{ Body: ReminderEmailBody }>( + "/reminder/send-email", + { + schema: { + body: { + type: "object", + properties: { + email: { type: "string" }, + language: { type: "string" }, + lowStock: { type: "array", items: lowStockItemSchema }, + }, + example: { + email: "daniel@example.com", + language: "en", + lowStock: [ + { + name: "Ibuprofen 400", + medsLeft: 4, + daysLeft: 2, + depletionDate: "2026-03-13", + isCritical: true, + }, + ], + }, + }, + response: { + 200: notificationResponseSchema, + 400: { anyOf: [genericErrorSchema, validationErrorSchema] }, + 401: genericErrorSchema, + 500: genericErrorSchema, + }, + }, + }, + async (request, reply) => { + const { email, lowStock } = request.body; + request.log.info( + { hasEmail: Boolean(email), lowStockCount: lowStock?.length ?? 0 }, + "[ReminderManual] Stock reminder request received" + ); + + if (!lowStock || lowStock.length === 0) { + return reply.status(400).send({ error: "Missing low stock data" }); + } + + // Load user settings + const userId = await getUserId(request); + const activeMeds = await db + .select({ name: medications.name, genericName: medications.genericName, packageType: medications.packageType }) + .from(medications) + .where(and(eq(medications.userId, userId), eq(medications.isObsolete, false))); + const activeMedicationByName = new Map( + activeMeds + .map((med) => [med.name || med.genericName || "", normalizePackageType(med.packageType)] as const) + .filter(([name]) => name.length > 0) + ); + const filteredLowStock = lowStock.filter((item) => { + const packageType = activeMedicationByName.get(item.name); + if (!packageType) return false; + if (isTubePackageType(packageType)) return false; + return true; + }); + if (filteredLowStock.length === 0) { + request.log.warn("[ReminderManual] Stock reminder skipped: no active medications after filtering"); + return reply.status(400).send({ error: "No active medications to notify" }); + } + + const userSettings = await loadUserSettings(userId); + const notificationSettings = { + emailEnabled: userSettings.emailEnabled, + shoutrrrEnabled: userSettings.shoutrrrEnabled, + shoutrrrUrl: userSettings.shoutrrrUrl || "", + }; + request.log.info( + { + userId, + emailEnabled: notificationSettings.emailEnabled, + pushEnabled: notificationSettings.shoutrrrEnabled, + hasPushUrl: Boolean(notificationSettings.shoutrrrUrl), + filteredLowStockCount: filteredLowStock.length, + }, + "[ReminderManual] Stock reminder channel state" + ); + + // Get translations based on user language + const language = (userSettings.language as Language) || "en"; + const tr = getTranslations(language); + + const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] }; + + // Separate into 3 categories: empty, critical, and low stock + const emptyMeds = filteredLowStock.filter((r) => r.medsLeft <= 0); + const criticalMeds = filteredLowStock.filter((r) => r.medsLeft > 0 && r.isCritical !== false); + const lowStockMeds = filteredLowStock.filter((r) => r.medsLeft > 0 && r.isCritical === false); + + // Build shared notification content (method-agnostic) + const titleParts: string[] = []; + if (emptyMeds.length > 0) { + titleParts.push(`🚨 ${emptyMeds.length} ${tr.push.empty}`); + } + if (criticalMeds.length > 0) { + titleParts.push(`🚨 ${criticalMeds.length} ${tr.push.critical}`); + } + if (lowStockMeds.length > 0) { + titleParts.push(`⚠️ ${lowStockMeds.length} ${tr.push.lowStock}`); + } + const notificationTitle = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.push.reorderNow}`; + + // Build description text + let descriptionText: string; + if (emptyMeds.length > 0 && (criticalMeds.length > 0 || lowStockMeds.length > 0)) { + descriptionText = tr.stockReminder.descriptionMixed; + } else if (emptyMeds.length > 0) { + descriptionText = tr.stockReminder.descriptionEmpty; + } else if (criticalMeds.length > 0) { + descriptionText = tr.stockReminder.description; + } else { + descriptionText = tr.stockReminder.descriptionLow; + } + + // Build section-based message (shared between email plain text and push) + const messageParts: string[] = []; + if (emptyMeds.length > 0) { + messageParts.push(`🚨 ${tr.push.emptySection}:`); + emptyMeds.forEach((r) => messageParts.push(` • ${r.name}`)); + } + if (criticalMeds.length > 0) { + if (messageParts.length > 0) messageParts.push(""); + messageParts.push(`🚨 ${tr.push.criticalSection}:`); + criticalMeds.forEach((r) => + messageParts.push( + ` • ${r.name}: ${t(tr.push.pillsLeft, { count: r.medsLeft })}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}` + ) + ); + } + if (lowStockMeds.length > 0) { + if (messageParts.length > 0) messageParts.push(""); + messageParts.push(`⚠️ ${tr.push.lowStockSection}:`); + lowStockMeds.forEach((r) => + messageParts.push( + ` • ${r.name}: ${t(tr.push.pillsLeft, { count: r.medsLeft })}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}` + ) + ); + } + + // Send email if enabled + if (notificationSettings.emailEnabled && email) { + const smtpHost = process.env.SMTP_HOST; + const smtpUser = process.env.SMTP_USER; + const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence + const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10); + const smtpSecure = process.env.SMTP_SECURE === "true"; + const smtpFrom = process.env.SMTP_FROM ?? smtpUser; + + request.log.info( { hasSmtpHost: Boolean(smtpHost), hasSmtpUser: Boolean(smtpUser), + hasSmtpPass: Boolean(smtpPass), + smtpPort, + smtpSecure, + hasSmtpFrom: Boolean(smtpFrom), to: maskEmail(email), }, - "[Planner] Demand email skipped: SMTP not configured" + "[ReminderManual] Stock email path selected" ); - } - } else { - request.log.info( - { emailEnabled: notificationSettings.emailEnabled, hasRecipient: Boolean(email) }, - "[Planner] Demand email channel not active" - ); - } - // Send push notification if enabled - if (notificationSettings.shoutrrrEnabled && notificationSettings.shoutrrrUrl) { - const pushTitle = t(dc.subject, { from: fromDate, until: untilDate }); - const pushMessage = `${summaryText}\n\n${activeRows - .map((r) => { - const usage = `${r.plannerUsage} ${getPlannerUnit(r.packageType, tr)}`; - const status = r.enough ? dc.statusEnough : dc.statusEmpty; - return `${r.enough ? "✓" : "✗"} ${r.medicationName}: ${usage} - ${status}`; - }) - .join("\n")}\n\n---\n${getFooterPlain(language)}`; + if (smtpHost && smtpUser) { + // Build subject line from shared title parts + const subjectText = titleParts.join(", "); - try { - const pushResult = await sendShoutrrrNotification(notificationSettings.shoutrrrUrl, pushTitle, pushMessage); - if (pushResult.success) { - results.push = true; - } else { - results.errors.push(`Push: ${pushResult.error}`); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error"; - results.errors.push(`Push: ${errorMessage}`); - } - } + // Build alert boxes for each category + const alertParts: string[] = []; - // Build response message - const sentChannels: string[] = []; - if (results.email) sentChannels.push("email"); - if (results.push) sentChannels.push("push"); - - if (sentChannels.length > 0) { - return reply.send({ - success: true, - message: `Notification sent via ${sentChannels.join(" and ")}`, - }); - } else if (results.errors.length > 0) { - return reply.status(500).send({ error: results.errors.join("; ") }); - } else { - return reply.status(400).send({ error: "No notification channels configured" }); - } - }); - - // Reminder notification for low stock medications (supports email and push) - app.post<{ Body: ReminderEmailBody }>("/reminder/send-email", async (request, reply) => { - const { email, lowStock } = request.body; - request.log.info( - { hasEmail: Boolean(email), lowStockCount: lowStock?.length ?? 0 }, - "[ReminderManual] Stock reminder request received" - ); - - if (!lowStock || lowStock.length === 0) { - return reply.status(400).send({ error: "Missing low stock data" }); - } - - // Load user settings - const userId = await getUserId(request); - const activeMeds = await db - .select({ name: medications.name, genericName: medications.genericName, packageType: medications.packageType }) - .from(medications) - .where(and(eq(medications.userId, userId), eq(medications.isObsolete, false))); - const activeMedicationByName = new Map( - activeMeds - .map((med) => [med.name || med.genericName || "", normalizePackageType(med.packageType)] as const) - .filter(([name]) => name.length > 0) - ); - const filteredLowStock = lowStock.filter((item) => { - const packageType = activeMedicationByName.get(item.name); - if (!packageType) return false; - if (isTubePackageType(packageType)) return false; - return true; - }); - if (filteredLowStock.length === 0) { - request.log.warn("[ReminderManual] Stock reminder skipped: no active medications after filtering"); - return reply.status(400).send({ error: "No active medications to notify" }); - } - - const userSettings = await loadUserSettings(userId); - const notificationSettings = { - emailEnabled: userSettings.emailEnabled, - shoutrrrEnabled: userSettings.shoutrrrEnabled, - shoutrrrUrl: userSettings.shoutrrrUrl || "", - }; - request.log.info( - { - userId, - emailEnabled: notificationSettings.emailEnabled, - pushEnabled: notificationSettings.shoutrrrEnabled, - hasPushUrl: Boolean(notificationSettings.shoutrrrUrl), - filteredLowStockCount: filteredLowStock.length, - }, - "[ReminderManual] Stock reminder channel state" - ); - - // Get translations based on user language - const language = (userSettings.language as Language) || "en"; - const tr = getTranslations(language); - - const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] }; - - // Separate into 3 categories: empty, critical, and low stock - const emptyMeds = filteredLowStock.filter((r) => r.medsLeft <= 0); - const criticalMeds = filteredLowStock.filter((r) => r.medsLeft > 0 && r.isCritical !== false); - const lowStockMeds = filteredLowStock.filter((r) => r.medsLeft > 0 && r.isCritical === false); - - // Build shared notification content (method-agnostic) - const titleParts: string[] = []; - if (emptyMeds.length > 0) { - titleParts.push(`🚨 ${emptyMeds.length} ${tr.push.empty}`); - } - if (criticalMeds.length > 0) { - titleParts.push(`🚨 ${criticalMeds.length} ${tr.push.critical}`); - } - if (lowStockMeds.length > 0) { - titleParts.push(`⚠️ ${lowStockMeds.length} ${tr.push.lowStock}`); - } - const notificationTitle = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.push.reorderNow}`; - - // Build description text - let descriptionText: string; - if (emptyMeds.length > 0 && (criticalMeds.length > 0 || lowStockMeds.length > 0)) { - descriptionText = tr.stockReminder.descriptionMixed; - } else if (emptyMeds.length > 0) { - descriptionText = tr.stockReminder.descriptionEmpty; - } else if (criticalMeds.length > 0) { - descriptionText = tr.stockReminder.description; - } else { - descriptionText = tr.stockReminder.descriptionLow; - } - - // Build section-based message (shared between email plain text and push) - const messageParts: string[] = []; - if (emptyMeds.length > 0) { - messageParts.push(`🚨 ${tr.push.emptySection}:`); - emptyMeds.forEach((r) => messageParts.push(` • ${r.name}`)); - } - if (criticalMeds.length > 0) { - if (messageParts.length > 0) messageParts.push(""); - messageParts.push(`🚨 ${tr.push.criticalSection}:`); - criticalMeds.forEach((r) => - messageParts.push( - ` • ${r.name}: ${t(tr.push.pillsLeft, { count: r.medsLeft })}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}` - ) - ); - } - if (lowStockMeds.length > 0) { - if (messageParts.length > 0) messageParts.push(""); - messageParts.push(`⚠️ ${tr.push.lowStockSection}:`); - lowStockMeds.forEach((r) => - messageParts.push( - ` • ${r.name}: ${t(tr.push.pillsLeft, { count: r.medsLeft })}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}` - ) - ); - } - - // Send email if enabled - if (notificationSettings.emailEnabled && email) { - const smtpHost = process.env.SMTP_HOST; - const smtpUser = process.env.SMTP_USER; - const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence - const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10); - const smtpSecure = process.env.SMTP_SECURE === "true"; - const smtpFrom = process.env.SMTP_FROM ?? smtpUser; - - request.log.info( - { - hasSmtpHost: Boolean(smtpHost), - hasSmtpUser: Boolean(smtpUser), - hasSmtpPass: Boolean(smtpPass), - smtpPort, - smtpSecure, - hasSmtpFrom: Boolean(smtpFrom), - to: maskEmail(email), - }, - "[ReminderManual] Stock email path selected" - ); - - if (smtpHost && smtpUser) { - // Build subject line from shared title parts - const subjectText = titleParts.join(", "); - - // Build alert boxes for each category - const alertParts: string[] = []; - - if (emptyMeds.length > 0) { - const emptyAlert = - emptyMeds.length === 1 - ? tr.stockReminder.alertEmptySingle - : t(tr.stockReminder.alertEmptyMultiple, { count: emptyMeds.length }); - alertParts.push(` + if (emptyMeds.length > 0) { + const emptyAlert = + emptyMeds.length === 1 + ? tr.stockReminder.alertEmptySingle + : t(tr.stockReminder.alertEmptyMultiple, { count: emptyMeds.length }); + alertParts.push(`

${emptyAlert}

`); - } + } - if (criticalMeds.length > 0) { - const criticalAlert = - criticalMeds.length === 1 - ? tr.stockReminder.alertLowSingle - : t(tr.stockReminder.alertLowMultiple, { count: criticalMeds.length }); - alertParts.push(` + if (criticalMeds.length > 0) { + const criticalAlert = + criticalMeds.length === 1 + ? tr.stockReminder.alertLowSingle + : t(tr.stockReminder.alertLowMultiple, { count: criticalMeds.length }); + alertParts.push(`

${criticalAlert}

`); - } + } - if (lowStockMeds.length > 0) { - const lowAlert = - lowStockMeds.length === 1 - ? tr.stockReminder.alertLowStockSingle - : t(tr.stockReminder.alertLowStockMultiple, { count: lowStockMeds.length }); - alertParts.push(` + if (lowStockMeds.length > 0) { + const lowAlert = + lowStockMeds.length === 1 + ? tr.stockReminder.alertLowStockSingle + : t(tr.stockReminder.alertLowStockMultiple, { count: lowStockMeds.length }); + alertParts.push(`

${lowAlert}

`); - } + } - const alertHtml = alertParts.join(""); + const alertHtml = alertParts.join(""); - // Build table rows with status indicator - const buildTableRow = (row: LowStockItem) => { - const isEmpty = row.medsLeft <= 0; - const isCritical = row.isCritical !== false; - const nonEmptyIcon = isCritical ? "🚨" : "⚠️"; - const statusIcon = isEmpty ? "🚨" : nonEmptyIcon; - const nonEmptyBg = isCritical ? "#fff7ed" : "white"; - const rowBg = isEmpty ? "#fef2f2" : nonEmptyBg; - const safeName = escapeHtml(row.name); - const safeMedsLeft = Number(row.medsLeft) || 0; - const safeDaysLeft = Number(row.daysLeft) || 0; - const safeDepletionDate = row.depletionDate ? escapeHtml(String(row.depletionDate)) : "-"; - return ` + // Build table rows with status indicator + const buildTableRow = (row: LowStockItem) => { + const isEmpty = row.medsLeft <= 0; + const isCritical = row.isCritical !== false; + const nonEmptyIcon = isCritical ? "🚨" : "⚠️"; + const statusIcon = isEmpty ? "🚨" : nonEmptyIcon; + const nonEmptyBg = isCritical ? "#fff7ed" : "white"; + const rowBg = isEmpty ? "#fef2f2" : nonEmptyBg; + const safeName = escapeHtml(row.name); + const safeMedsLeft = Number(row.medsLeft) || 0; + const safeDaysLeft = Number(row.daysLeft) || 0; + const safeDepletionDate = row.depletionDate ? escapeHtml(String(row.depletionDate)) : "-"; + return ` ${statusIcon} ${safeName} ${safeMedsLeft} ${safeDaysLeft} ${isEmpty ? `${tr.stockReminder.now}` : safeDepletionDate} `; - }; + }; - const tableRows = filteredLowStock.map(buildTableRow).join(""); + const tableRows = filteredLowStock.map(buildTableRow).join(""); - const html = ` + const html = `

${emptyMeds.length > 0 ? "🚨" : "⚠️"} MedAssist-ng - ${tr.push.reorderNow}

@@ -704,227 +842,264 @@ ${getFooterPlain(language)}`;
`; - const plainText = `MedAssist-ng - ${tr.push.reorderNow}\n\n${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`; + const plainText = `MedAssist-ng - ${tr.push.reorderNow}\n\n${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`; - try { - const transporter = nodemailer.createTransport({ - host: smtpHost, - port: smtpPort, - secure: smtpSecure, - auth: { - user: smtpUser, - pass: smtpPass ?? "", - }, - }); + try { + const transporter = nodemailer.createTransport({ + host: smtpHost, + port: smtpPort, + secure: smtpSecure, + auth: { + user: smtpUser, + pass: smtpPass ?? "", + }, + }); - request.log.info({ to: maskEmail(email) }, "[ReminderManual] Sending stock reminder email"); + request.log.info({ to: maskEmail(email) }, "[ReminderManual] Sending stock reminder email"); - const mailResult = await transporter.sendMail({ - from: smtpFrom, - to: email, - subject: `MedAssist-ng: ${subjectText}`, - text: plainText, - html, - }); + const mailResult = await transporter.sendMail({ + from: smtpFrom, + to: email, + subject: `MedAssist-ng: ${subjectText}`, + text: plainText, + html, + }); - const deliveryError = getDeliveryError(mailResult); - if (deliveryError) { - throw new Error(deliveryError); + const deliveryError = getDeliveryError(mailResult); + if (deliveryError) { + throw new Error(deliveryError); + } + + request.log.info( + { to: maskEmail(email), messageId: mailResult.messageId }, + "[ReminderManual] Stock reminder email sent" + ); + results.email = true; + } catch (error) { + request.log.error({ error, to: maskEmail(email) }, "[ReminderManual] Stock reminder email failed"); + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + results.errors.push(`Email: ${errorMessage}`); } - - request.log.info( - { to: maskEmail(email), messageId: mailResult.messageId }, - "[ReminderManual] Stock reminder email sent" + } else { + request.log.warn( + { + hasSmtpHost: Boolean(smtpHost), + hasSmtpUser: Boolean(smtpUser), + to: maskEmail(email), + }, + "[ReminderManual] Stock reminder email skipped: SMTP not configured" ); - results.email = true; - } catch (error) { - request.log.error({ error, to: maskEmail(email) }, "[ReminderManual] Stock reminder email failed"); - const errorMessage = error instanceof Error ? error.message : "Unknown error"; - results.errors.push(`Email: ${errorMessage}`); } } else { - request.log.warn( + request.log.info( + { emailEnabled: notificationSettings.emailEnabled, hasRecipient: Boolean(email) }, + "[ReminderManual] Stock email channel not active" + ); + } + + // Send push notification if enabled + if (notificationSettings.shoutrrrEnabled && notificationSettings.shoutrrrUrl) { + const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`; + + try { + const pushResult = await sendShoutrrrNotification( + notificationSettings.shoutrrrUrl, + notificationTitle, + message + ); + if (pushResult.success) { + results.push = true; + } else { + results.errors.push(`Push: ${pushResult.error}`); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + results.errors.push(`Push: ${errorMessage}`); + } + } + + // Update the reminder state to record this notification was sent + if (results.email || results.push) { + const singleChannel = results.email ? "email" : "push"; + const channel = results.email && results.push ? "both" : singleChannel; + updateReminderSentTime("stock", channel); + + // Also update user settings in database so frontend can display the info + const medNames = filteredLowStock.map((m: { name: string }) => m.name).join(", "); + await updateUserReminderSentTime(userId, "stock", channel, medNames); + } + + // Build response message + const sentChannels: string[] = []; + if (results.email) sentChannels.push("email"); + if (results.push) sentChannels.push("push"); + + if (sentChannels.length > 0) { + return reply.send({ + success: true, + message: `Reminder sent via ${sentChannels.join(" and ")}`, + }); + } else if (results.errors.length > 0) { + return reply.status(500).send({ error: results.errors.join("; ") }); + } else { + return reply.status(400).send({ error: "No notification channels configured" }); + } + } + ); + + // Manual prescription reminder (supports email and push) + app.post<{ Body: PrescriptionReminderBody }>( + "/reminder/send-prescription", + { + schema: { + body: { + type: "object", + properties: { + email: { type: "string" }, + language: { type: "string" }, + prescriptionLow: { type: "array", items: prescriptionReminderItemSchema }, + }, + example: { + email: "daniel@example.com", + language: "en", + prescriptionLow: [ + { + name: "Ibuprofen 400", + remainingRefills: 1, + threshold: 1, + expiryDate: "2026-06-30", + }, + ], + }, + }, + response: { + 200: notificationResponseSchema, + 400: { anyOf: [genericErrorSchema, validationErrorSchema] }, + 401: genericErrorSchema, + 500: genericErrorSchema, + }, + }, + }, + async (request, reply) => { + const { email, prescriptionLow } = request.body; + request.log.info( + { hasEmail: Boolean(email), prescriptionCount: prescriptionLow?.length ?? 0 }, + "[ReminderManual] Prescription reminder request received" + ); + + if (!prescriptionLow || prescriptionLow.length === 0) { + return reply.status(400).send({ error: "Missing prescription reminder data" }); + } + + const userId = await getUserId(request); + const activeMeds = await db + .select({ name: medications.name, genericName: medications.genericName }) + .from(medications) + .where(and(eq(medications.userId, userId), eq(medications.isObsolete, false))); + const activeMedNames = new Set(activeMeds.map((med) => med.name || med.genericName || "")); + const filteredPrescriptionLow = prescriptionLow.filter((item) => activeMedNames.has(item.name)); + if (filteredPrescriptionLow.length === 0) { + request.log.warn("[ReminderManual] Prescription reminder skipped: no active medications after filtering"); + return reply.status(400).send({ error: "No active medications to notify" }); + } + + const userSettings = await loadUserSettings(userId); + const language = (userSettings.language as Language) || "en"; + const tr = getTranslations(language); + + const emptyRx = filteredPrescriptionLow.filter((item) => item.remainingRefills <= 0); + const lowRx = filteredPrescriptionLow.filter((item) => item.remainingRefills > 0); + + const lines = filteredPrescriptionLow.map((item) => { + const expirySuffix = item.expiryDate ? t(tr.prescriptionReminder.expiresSuffix, { date: item.expiryDate }) : ""; + if (item.remainingRefills <= 0) { + return `- ${t(tr.prescriptionReminder.lineEmpty, { + name: item.name, + expirySuffix, + })}`; + } + return `- ${t(tr.prescriptionReminder.line, { + name: item.name, + refills: item.remainingRefills, + expirySuffix, + })}`; + }); + + const medNames = filteredPrescriptionLow.map((m: { name: string }) => m.name).join(", "); + + const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] }; + + if (userSettings.emailEnabled && userSettings.emailPrescriptionReminders && email) { + const smtpHost = process.env.SMTP_HOST; + const smtpUser = process.env.SMTP_USER; + const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; + const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10); + const smtpSecure = process.env.SMTP_SECURE === "true"; + const smtpFrom = process.env.SMTP_FROM ?? smtpUser; + + request.log.info( { hasSmtpHost: Boolean(smtpHost), hasSmtpUser: Boolean(smtpUser), + hasSmtpPass: Boolean(smtpPass), + smtpPort, + smtpSecure, + hasSmtpFrom: Boolean(smtpFrom), to: maskEmail(email), }, - "[ReminderManual] Stock reminder email skipped: SMTP not configured" + "[ReminderManual] Prescription email path selected" ); - } - } else { - request.log.info( - { emailEnabled: notificationSettings.emailEnabled, hasRecipient: Boolean(email) }, - "[ReminderManual] Stock email channel not active" - ); - } - // Send push notification if enabled - if (notificationSettings.shoutrrrEnabled && notificationSettings.shoutrrrUrl) { - const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`; + if (smtpHost && smtpUser) { + try { + const transporter = nodemailer.createTransport({ + host: smtpHost, + port: smtpPort, + secure: smtpSecure, + auth: { + user: smtpUser, + pass: smtpPass ?? "", + }, + }); - try { - const pushResult = await sendShoutrrrNotification(notificationSettings.shoutrrrUrl, notificationTitle, message); - if (pushResult.success) { - results.push = true; - } else { - results.errors.push(`Push: ${pushResult.error}`); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error"; - results.errors.push(`Push: ${errorMessage}`); - } - } + const subject = + filteredPrescriptionLow.length === 1 + ? tr.prescriptionReminder.subjectSingle + : t(tr.prescriptionReminder.subjectMultiple, { count: filteredPrescriptionLow.length }); - // Update the reminder state to record this notification was sent - if (results.email || results.push) { - const singleChannel = results.email ? "email" : "push"; - const channel = results.email && results.push ? "both" : singleChannel; - updateReminderSentTime("stock", channel); + const bodyText = + emptyRx.length > 0 ? tr.prescriptionReminder.descriptionEmpty : tr.prescriptionReminder.descriptionLow; + const emptyAlert = + emptyRx.length === 1 + ? tr.prescriptionReminder.alertEmptySingle + : t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length }); + const lowAlert = + lowRx.length === 1 + ? tr.prescriptionReminder.alertLowSingle + : t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length }); + const alertText = emptyRx.length > 0 ? emptyAlert : lowAlert; - // Also update user settings in database so frontend can display the info - const medNames = filteredLowStock.map((m: { name: string }) => m.name).join(", "); - await updateUserReminderSentTime(userId, "stock", channel, medNames); - } - - // Build response message - const sentChannels: string[] = []; - if (results.email) sentChannels.push("email"); - if (results.push) sentChannels.push("push"); - - if (sentChannels.length > 0) { - return reply.send({ - success: true, - message: `Reminder sent via ${sentChannels.join(" and ")}`, - }); - } else if (results.errors.length > 0) { - return reply.status(500).send({ error: results.errors.join("; ") }); - } else { - return reply.status(400).send({ error: "No notification channels configured" }); - } - }); - - // Manual prescription reminder (supports email and push) - app.post<{ Body: PrescriptionReminderBody }>("/reminder/send-prescription", async (request, reply) => { - const { email, prescriptionLow } = request.body; - request.log.info( - { hasEmail: Boolean(email), prescriptionCount: prescriptionLow?.length ?? 0 }, - "[ReminderManual] Prescription reminder request received" - ); - - if (!prescriptionLow || prescriptionLow.length === 0) { - return reply.status(400).send({ error: "Missing prescription reminder data" }); - } - - const userId = await getUserId(request); - const activeMeds = await db - .select({ name: medications.name, genericName: medications.genericName }) - .from(medications) - .where(and(eq(medications.userId, userId), eq(medications.isObsolete, false))); - const activeMedNames = new Set(activeMeds.map((med) => med.name || med.genericName || "")); - const filteredPrescriptionLow = prescriptionLow.filter((item) => activeMedNames.has(item.name)); - if (filteredPrescriptionLow.length === 0) { - request.log.warn("[ReminderManual] Prescription reminder skipped: no active medications after filtering"); - return reply.status(400).send({ error: "No active medications to notify" }); - } - - const userSettings = await loadUserSettings(userId); - const language = (userSettings.language as Language) || "en"; - const tr = getTranslations(language); - - const emptyRx = filteredPrescriptionLow.filter((item) => item.remainingRefills <= 0); - const lowRx = filteredPrescriptionLow.filter((item) => item.remainingRefills > 0); - - const lines = filteredPrescriptionLow.map((item) => { - const expirySuffix = item.expiryDate ? t(tr.prescriptionReminder.expiresSuffix, { date: item.expiryDate }) : ""; - if (item.remainingRefills <= 0) { - return `- ${t(tr.prescriptionReminder.lineEmpty, { - name: item.name, - expirySuffix, - })}`; - } - return `- ${t(tr.prescriptionReminder.line, { - name: item.name, - refills: item.remainingRefills, - expirySuffix, - })}`; - }); - - const medNames = filteredPrescriptionLow.map((m: { name: string }) => m.name).join(", "); - - const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] }; - - if (userSettings.emailEnabled && userSettings.emailPrescriptionReminders && email) { - const smtpHost = process.env.SMTP_HOST; - const smtpUser = process.env.SMTP_USER; - const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; - const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10); - const smtpSecure = process.env.SMTP_SECURE === "true"; - const smtpFrom = process.env.SMTP_FROM ?? smtpUser; - - request.log.info( - { - hasSmtpHost: Boolean(smtpHost), - hasSmtpUser: Boolean(smtpUser), - hasSmtpPass: Boolean(smtpPass), - smtpPort, - smtpSecure, - hasSmtpFrom: Boolean(smtpFrom), - to: maskEmail(email), - }, - "[ReminderManual] Prescription email path selected" - ); - - if (smtpHost && smtpUser) { - try { - const transporter = nodemailer.createTransport({ - host: smtpHost, - port: smtpPort, - secure: smtpSecure, - auth: { - user: smtpUser, - pass: smtpPass ?? "", - }, - }); - - const subject = - filteredPrescriptionLow.length === 1 - ? tr.prescriptionReminder.subjectSingle - : t(tr.prescriptionReminder.subjectMultiple, { count: filteredPrescriptionLow.length }); - - const bodyText = - emptyRx.length > 0 ? tr.prescriptionReminder.descriptionEmpty : tr.prescriptionReminder.descriptionLow; - const emptyAlert = - emptyRx.length === 1 - ? tr.prescriptionReminder.alertEmptySingle - : t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length }); - const lowAlert = - lowRx.length === 1 - ? tr.prescriptionReminder.alertLowSingle - : t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length }); - const alertText = emptyRx.length > 0 ? emptyAlert : lowAlert; - - const tableRows = filteredPrescriptionLow - .map((item) => { - const isEmpty = item.remainingRefills <= 0; - const safeName = escapeHtml(item.name); - const safeRefills = Number(item.remainingRefills) || 0; - const safeThreshold = Number(item.threshold) || 0; - const safeExpiry = item.expiryDate ? escapeHtml(String(item.expiryDate)) : "-"; - const rowBg = isEmpty ? "#fef2f2" : "white"; - return ` + const tableRows = filteredPrescriptionLow + .map((item) => { + const isEmpty = item.remainingRefills <= 0; + const safeName = escapeHtml(item.name); + const safeRefills = Number(item.remainingRefills) || 0; + const safeThreshold = Number(item.threshold) || 0; + const safeExpiry = item.expiryDate ? escapeHtml(String(item.expiryDate)) : "-"; + const rowBg = isEmpty ? "#fef2f2" : "white"; + return ` ${isEmpty ? "🚨" : "⚠️"} ${safeName} ${safeRefills} ${safeThreshold} ${safeExpiry} `; - }) - .join(""); + }) + .join(""); - const emailTitle = emptyRx.length > 0 ? tr.prescriptionReminder.titleEmpty : tr.prescriptionReminder.title; - const text = `${emailTitle}\n\n${bodyText}\n\n${lines.join("\n")}\n\n---\n${getFooterPlain(language)}`; - const html = ` + const emailTitle = emptyRx.length > 0 ? tr.prescriptionReminder.titleEmpty : tr.prescriptionReminder.title; + const text = `${emailTitle}\n\n${bodyText}\n\n${lines.join("\n")}\n\n---\n${getFooterPlain(language)}`; + const html = `

${emailTitle}

@@ -958,117 +1133,118 @@ ${getFooterPlain(language)}`;
`; - request.log.info({ to: maskEmail(email) }, "[ReminderManual] Sending prescription reminder email"); + request.log.info({ to: maskEmail(email) }, "[ReminderManual] Sending prescription reminder email"); - const mailResult = await transporter.sendMail({ - from: smtpFrom, - to: email, - subject, - text, - html, - }); + const mailResult = await transporter.sendMail({ + from: smtpFrom, + to: email, + subject, + text, + html, + }); - const deliveryError = getDeliveryError(mailResult); - if (deliveryError) { - throw new Error(deliveryError); + const deliveryError = getDeliveryError(mailResult); + if (deliveryError) { + throw new Error(deliveryError); + } + + request.log.info( + { to: maskEmail(email), messageId: mailResult.messageId }, + "[ReminderManual] Prescription reminder email sent" + ); + results.email = true; + } catch (error) { + request.log.error({ error, to: maskEmail(email) }, "[ReminderManual] Prescription reminder email failed"); + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + results.errors.push(`Email: ${errorMessage}`); } - - request.log.info( - { to: maskEmail(email), messageId: mailResult.messageId }, - "[ReminderManual] Prescription reminder email sent" + } else { + request.log.warn( + { + hasSmtpHost: Boolean(smtpHost), + hasSmtpUser: Boolean(smtpUser), + to: maskEmail(email), + }, + "[ReminderManual] Prescription reminder email skipped: SMTP not configured" ); - results.email = true; - } catch (error) { - request.log.error({ error, to: maskEmail(email) }, "[ReminderManual] Prescription reminder email failed"); - const errorMessage = error instanceof Error ? error.message : "Unknown error"; - results.errors.push(`Email: ${errorMessage}`); } } else { - request.log.warn( + request.log.info( { - hasSmtpHost: Boolean(smtpHost), - hasSmtpUser: Boolean(smtpUser), - to: maskEmail(email), + emailEnabled: userSettings.emailEnabled, + emailPrescriptionReminders: userSettings.emailPrescriptionReminders, + hasRecipient: Boolean(email), }, - "[ReminderManual] Prescription reminder email skipped: SMTP not configured" + "[ReminderManual] Prescription email channel not active" ); } - } else { - request.log.info( - { - emailEnabled: userSettings.emailEnabled, - emailPrescriptionReminders: userSettings.emailPrescriptionReminders, - hasRecipient: Boolean(email), - }, - "[ReminderManual] Prescription email channel not active" - ); - } - if (userSettings.shoutrrrEnabled && userSettings.shoutrrrPrescriptionReminders && userSettings.shoutrrrUrl) { - const titleParts: string[] = []; - if (emptyRx.length > 0) - titleParts.push( - `🚨 ${emptyRx.length} ${emptyRx.length === 1 ? tr.prescriptionReminder.pushEmptySingle : tr.prescriptionReminder.pushEmpty}` - ); - if (lowRx.length > 0) - titleParts.push( - `🚨 ${lowRx.length} ${lowRx.length === 1 ? tr.prescriptionReminder.pushLowSingle : tr.prescriptionReminder.pushLow}` - ); - const title = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.prescriptionReminder.pushRenewNow}`; - - const messageParts: string[] = []; - if (emptyRx.length > 0) { - messageParts.push(`🚨 ${tr.prescriptionReminder.pushEmptySection}:`); - for (const m of emptyRx) { - messageParts.push(` • ${m.name}`); - } - } - if (lowRx.length > 0) { - if (emptyRx.length > 0) messageParts.push(""); - messageParts.push(`🚨 ${tr.prescriptionReminder.pushLowSection}:`); - for (const m of lowRx) { - messageParts.push( - ` • ${m.name}: ${t(tr.prescriptionReminder.pushRefillsLeft, { count: m.remainingRefills })}` + if (userSettings.shoutrrrEnabled && userSettings.shoutrrrPrescriptionReminders && userSettings.shoutrrrUrl) { + const titleParts: string[] = []; + if (emptyRx.length > 0) + titleParts.push( + `🚨 ${emptyRx.length} ${emptyRx.length === 1 ? tr.prescriptionReminder.pushEmptySingle : tr.prescriptionReminder.pushEmpty}` ); + if (lowRx.length > 0) + titleParts.push( + `🚨 ${lowRx.length} ${lowRx.length === 1 ? tr.prescriptionReminder.pushLowSingle : tr.prescriptionReminder.pushLow}` + ); + const title = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.prescriptionReminder.pushRenewNow}`; + + const messageParts: string[] = []; + if (emptyRx.length > 0) { + messageParts.push(`🚨 ${tr.prescriptionReminder.pushEmptySection}:`); + for (const m of emptyRx) { + messageParts.push(` • ${m.name}`); + } + } + if (lowRx.length > 0) { + if (emptyRx.length > 0) messageParts.push(""); + messageParts.push(`🚨 ${tr.prescriptionReminder.pushLowSection}:`); + for (const m of lowRx) { + messageParts.push( + ` • ${m.name}: ${t(tr.prescriptionReminder.pushRefillsLeft, { count: m.remainingRefills })}` + ); + } + } + const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`; + + try { + const pushResult = await sendShoutrrrNotification(userSettings.shoutrrrUrl, title, message); + if (pushResult.success) { + results.push = true; + } else { + results.errors.push(`Push: ${pushResult.error}`); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + results.errors.push(`Push: ${errorMessage}`); } } - const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`; - try { - const pushResult = await sendShoutrrrNotification(userSettings.shoutrrrUrl, title, message); - if (pushResult.success) { - results.push = true; - } else { - results.errors.push(`Push: ${pushResult.error}`); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error"; - results.errors.push(`Push: ${errorMessage}`); + if (results.email || results.push) { + const singleChannel = results.email ? "email" : "push"; + const channel = results.email && results.push ? "both" : singleChannel; + updateReminderSentTime("prescription", channel); + await updateUserReminderSentTime(userId, "prescription", channel, medNames); } + + const sentChannels: string[] = []; + if (results.email) sentChannels.push("email"); + if (results.push) sentChannels.push("push"); + + if (sentChannels.length > 0) { + return reply.send({ + success: true, + message: `Prescription reminder sent via ${sentChannels.join(" and ")}`, + }); + } + + if (results.errors.length > 0) { + return reply.status(500).send({ error: results.errors.join("; ") }); + } + + return reply.status(400).send({ error: "No notification channels configured" }); } - - if (results.email || results.push) { - const singleChannel = results.email ? "email" : "push"; - const channel = results.email && results.push ? "both" : singleChannel; - updateReminderSentTime("prescription", channel); - await updateUserReminderSentTime(userId, "prescription", channel, medNames); - } - - const sentChannels: string[] = []; - if (results.email) sentChannels.push("email"); - if (results.push) sentChannels.push("push"); - - if (sentChannels.length > 0) { - return reply.send({ - success: true, - message: `Prescription reminder sent via ${sentChannels.join(" and ")}`, - }); - } - - if (results.errors.length > 0) { - return reply.status(500).send({ error: results.errors.join("; ") }); - } - - return reply.status(400).send({ error: "No notification channels configured" }); - }); + ); } diff --git a/backend/src/routes/refills.ts b/backend/src/routes/refills.ts index 73edb35..da2d2ad 100644 --- a/backend/src/routes/refills.ts +++ b/backend/src/routes/refills.ts @@ -6,7 +6,12 @@ import { medications, refillHistory } from "../db/schema.js"; import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; import { env } from "../plugins/env.js"; import type { AuthUser } from "../types/fastify.js"; -import { applyOpenApiRouteStandards } from "../utils/openapi-route-standards.js"; +import { + applyOpenApiRouteStandards, + genericErrorSchema, + idParamsSchema, + validationErrorSchema, +} from "../utils/openapi-route-standards.js"; import { isAmountBasedPackageType, normalizePackageType } from "../utils/package-profiles.js"; const refillSchema = z @@ -19,6 +24,68 @@ const refillSchema = z message: "Must add at least one pack or some loose pills", }); +const refillBodyOpenApiSchema = { + type: "object", + properties: { + packsAdded: { type: "integer", minimum: 0, default: 0 }, + loosePillsAdded: { type: "integer", minimum: 0, default: 0 }, + usePrescription: { type: "boolean", default: false }, + }, + description: "Provide at least one pack or some loose pills.", + example: { + packsAdded: 1, + loosePillsAdded: 4, + usePrescription: true, + }, +} as const; + +const refillResponseSchema = { + type: "object", + properties: { + success: { type: "boolean" }, + refill: { + type: "object", + properties: { + id: { type: "number" }, + packsAdded: { type: "integer" }, + loosePillsAdded: { type: "integer" }, + totalPillsAdded: { type: "number" }, + refillDate: { type: "string", format: "date-time" }, + }, + }, + newStock: { + type: "object", + properties: { + packCount: { type: "integer" }, + looseTablets: { type: "integer" }, + totalPills: { type: "number" }, + }, + }, + prescription: { + type: "object", + properties: { + used: { type: "boolean" }, + remainingRefills: { type: "integer" }, + authorizedRefills: { type: "integer" }, + lowRefillThreshold: { type: "integer" }, + enabled: { type: "boolean" }, + }, + }, + }, +} as const; + +const refillHistoryItemSchema = { + type: "object", + properties: { + id: { type: "number" }, + packsAdded: { type: "integer" }, + loosePillsAdded: { type: "integer" }, + totalPillsAdded: { type: "number" }, + usedPrescription: { type: "boolean" }, + refillDate: { type: "string", format: "date-time" }, + }, +} as const; + export async function refillRoutes(app: FastifyInstance) { // All refill routes require auth app.addHook("preHandler", requireAuth); @@ -38,188 +105,218 @@ export async function refillRoutes(app: FastifyInstance) { } // POST /medications/:id/refill - Add stock to medication - app.post<{ Params: { id: string } }>("/medications/:id/refill", async (req, reply) => { - const parsed = refillSchema.safeParse(req.body); - if (!parsed.success) return reply.status(400).send(parsed.error.format()); - - const medId = Number(req.params.id); - if (Number.isNaN(medId)) return reply.badRequest("Invalid medication id"); - - const userId = await getUserId(req, reply); - - // Verify ownership - const [med] = await db - .select() - .from(medications) - .where(and(eq(medications.id, medId), eq(medications.userId, userId))); - if (!med) return reply.notFound("Medication not found"); - - const { packsAdded, loosePillsAdded, usePrescription } = parsed.data; - const packageType = normalizePackageType(med.packageType); - const isBottle = packageType === "bottle"; - const isAmountBased = isAmountBasedPackageType(packageType); - const isCountBasedAmountPackage = isAmountBased && !isBottle; - - const configuredAmountPerPackage = Number(med.packageAmountValue ?? 0); - const fallbackAmountPerPackage = Math.max( - 1, - Math.round((med.totalPills ?? med.looseTablets ?? 0) / Math.max(1, med.packCount || 1)) - ); - const amountPerPackage = - Number.isFinite(configuredAmountPerPackage) && configuredAmountPerPackage > 0 - ? configuredAmountPerPackage - : fallbackAmountPerPackage; - - const requestedPackAdds = Math.max(0, packsAdded); - const requestedAmountAdds = Math.max(0, loosePillsAdded); - const derivedCountFromAmount = Math.max(0, Math.round(requestedAmountAdds / amountPerPackage)); - - let effectivePacksAdded = requestedPackAdds; - if (isBottle) { - effectivePacksAdded = 0; - } else if (isCountBasedAmountPackage) { - effectivePacksAdded = Math.max(requestedPackAdds, derivedCountFromAmount); - } - const effectiveLoosePillsAdded = isCountBasedAmountPackage - ? effectivePacksAdded * amountPerPackage - : requestedAmountAdds; - const remainingPrescriptionRefills = med.prescriptionRemainingRefills ?? 0; - - if (effectivePacksAdded < 1 && effectiveLoosePillsAdded < 1) { - return reply.status(400).send({ error: "Must add at least one pack or some loose pills" }); - } - - if (usePrescription) { - if (!(med.prescriptionEnabled ?? false)) { - return reply.status(400).send({ error: "Prescription refill is not enabled for this medication" }); - } - if (remainingPrescriptionRefills <= 0) { - return reply.status(409).send({ error: "No remaining prescription refills" }); - } - if (!isBottle && effectivePacksAdded > remainingPrescriptionRefills) { - return reply.status(409).send({ error: "Packs to add exceed remaining prescription refills" }); - } - } - - // Update medication stock - const newPackCount = med.packCount + effectivePacksAdded; - const newLooseTablets = med.looseTablets + effectiveLoosePillsAdded; - const previousAmountBase = med.totalPills ?? med.looseTablets; - const newTotalAmount = previousAmountBase + effectiveLoosePillsAdded; - - let consumedRefills = 0; - if (usePrescription) { - consumedRefills = isBottle ? 1 : effectivePacksAdded; - } - const newRemainingRefills = usePrescription - ? Math.max(0, remainingPrescriptionRefills - consumedRefills) - : (med.prescriptionRemainingRefills ?? null); - - const updatePayload: { - packCount: number; - looseTablets: number; - totalPills?: number; - packageAmountValue?: number; - prescriptionRemainingRefills: number | null; - updatedAt: Date; - } = { - packCount: newPackCount, - looseTablets: newLooseTablets, - prescriptionRemainingRefills: newRemainingRefills, - updatedAt: new Date(), - }; - - if (isCountBasedAmountPackage) { - updatePayload.totalPills = newTotalAmount; - updatePayload.packageAmountValue = amountPerPackage; - } - - await db - .update(medications) - .set(updatePayload) - .where(and(eq(medications.id, medId), eq(medications.userId, userId))); - - // Create refill history entry - const [refill] = await db - .insert(refillHistory) - .values({ - medicationId: medId, - userId, - packsAdded: effectivePacksAdded, - loosePillsAdded: effectiveLoosePillsAdded, - usedPrescription: usePrescription, - }) - .returning(); - - // Calculate pills added for response (packageType-aware) - const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister; - const totalPillsAdded = isAmountBased - ? effectiveLoosePillsAdded - : effectivePacksAdded * pillsPerPack + effectiveLoosePillsAdded; - let newTotalPills = newPackCount * pillsPerPack + newLooseTablets + (med.stockAdjustment ?? 0); - if (isCountBasedAmountPackage) { - newTotalPills = (newTotalAmount ?? 0) + (med.stockAdjustment ?? 0); - } else if (isBottle) { - newTotalPills = newLooseTablets + (med.stockAdjustment ?? 0); - } - - return { - success: true, - refill: { - id: refill.id, - packsAdded: effectivePacksAdded, - loosePillsAdded: effectiveLoosePillsAdded, - totalPillsAdded, - refillDate: refill.refillDate, + app.post<{ Params: { id: string } }>( + "/medications/:id/refill", + { + schema: { + params: idParamsSchema, + body: refillBodyOpenApiSchema, + response: { + 200: refillResponseSchema, + 400: { anyOf: [genericErrorSchema, validationErrorSchema] }, + 401: genericErrorSchema, + 404: genericErrorSchema, + 409: genericErrorSchema, + }, }, - newStock: { + }, + async (req, reply) => { + const parsed = refillSchema.safeParse(req.body); + if (!parsed.success) return reply.status(400).send(parsed.error.format()); + + const medId = Number(req.params.id); + if (Number.isNaN(medId)) return reply.badRequest("Invalid medication id"); + + const userId = await getUserId(req, reply); + + // Verify ownership + const [med] = await db + .select() + .from(medications) + .where(and(eq(medications.id, medId), eq(medications.userId, userId))); + if (!med) return reply.notFound("Medication not found"); + + const { packsAdded, loosePillsAdded, usePrescription } = parsed.data; + const packageType = normalizePackageType(med.packageType); + const isBottle = packageType === "bottle"; + const isAmountBased = isAmountBasedPackageType(packageType); + const isCountBasedAmountPackage = isAmountBased && !isBottle; + + const configuredAmountPerPackage = Number(med.packageAmountValue ?? 0); + const fallbackAmountPerPackage = Math.max( + 1, + Math.round((med.totalPills ?? med.looseTablets ?? 0) / Math.max(1, med.packCount || 1)) + ); + const amountPerPackage = + Number.isFinite(configuredAmountPerPackage) && configuredAmountPerPackage > 0 + ? configuredAmountPerPackage + : fallbackAmountPerPackage; + + const requestedPackAdds = Math.max(0, packsAdded); + const requestedAmountAdds = Math.max(0, loosePillsAdded); + const derivedCountFromAmount = Math.max(0, Math.round(requestedAmountAdds / amountPerPackage)); + + let effectivePacksAdded = requestedPackAdds; + if (isBottle) { + effectivePacksAdded = 0; + } else if (isCountBasedAmountPackage) { + effectivePacksAdded = Math.max(requestedPackAdds, derivedCountFromAmount); + } + const effectiveLoosePillsAdded = isCountBasedAmountPackage + ? effectivePacksAdded * amountPerPackage + : requestedAmountAdds; + const remainingPrescriptionRefills = med.prescriptionRemainingRefills ?? 0; + + if (effectivePacksAdded < 1 && effectiveLoosePillsAdded < 1) { + return reply.status(400).send({ error: "Must add at least one pack or some loose pills" }); + } + + if (usePrescription) { + if (!(med.prescriptionEnabled ?? false)) { + return reply.status(400).send({ error: "Prescription refill is not enabled for this medication" }); + } + if (remainingPrescriptionRefills <= 0) { + return reply.status(409).send({ error: "No remaining prescription refills" }); + } + if (!isBottle && effectivePacksAdded > remainingPrescriptionRefills) { + return reply.status(409).send({ error: "Packs to add exceed remaining prescription refills" }); + } + } + + // Update medication stock + const newPackCount = med.packCount + effectivePacksAdded; + const newLooseTablets = med.looseTablets + effectiveLoosePillsAdded; + const previousAmountBase = med.totalPills ?? med.looseTablets; + const newTotalAmount = previousAmountBase + effectiveLoosePillsAdded; + + let consumedRefills = 0; + if (usePrescription) { + consumedRefills = isBottle ? 1 : effectivePacksAdded; + } + const newRemainingRefills = usePrescription + ? Math.max(0, remainingPrescriptionRefills - consumedRefills) + : (med.prescriptionRemainingRefills ?? null); + + const updatePayload: { + packCount: number; + looseTablets: number; + totalPills?: number; + packageAmountValue?: number; + prescriptionRemainingRefills: number | null; + updatedAt: Date; + } = { packCount: newPackCount, looseTablets: newLooseTablets, - totalPills: newTotalPills, - }, - prescription: { - used: usePrescription, - remainingRefills: newRemainingRefills, - authorizedRefills: med.prescriptionAuthorizedRefills ?? null, - lowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1, - enabled: med.prescriptionEnabled ?? false, - }, - }; - }); + prescriptionRemainingRefills: newRemainingRefills, + updatedAt: new Date(), + }; + + if (isCountBasedAmountPackage) { + updatePayload.totalPills = newTotalAmount; + updatePayload.packageAmountValue = amountPerPackage; + } + + await db + .update(medications) + .set(updatePayload) + .where(and(eq(medications.id, medId), eq(medications.userId, userId))); + + // Create refill history entry + const [refill] = await db + .insert(refillHistory) + .values({ + medicationId: medId, + userId, + packsAdded: effectivePacksAdded, + loosePillsAdded: effectiveLoosePillsAdded, + usedPrescription: usePrescription, + }) + .returning(); + + // Calculate pills added for response (packageType-aware) + const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister; + const totalPillsAdded = isAmountBased + ? effectiveLoosePillsAdded + : effectivePacksAdded * pillsPerPack + effectiveLoosePillsAdded; + let newTotalPills = newPackCount * pillsPerPack + newLooseTablets + (med.stockAdjustment ?? 0); + if (isCountBasedAmountPackage) { + newTotalPills = (newTotalAmount ?? 0) + (med.stockAdjustment ?? 0); + } else if (isBottle) { + newTotalPills = newLooseTablets + (med.stockAdjustment ?? 0); + } + + return { + success: true, + refill: { + id: refill.id, + packsAdded: effectivePacksAdded, + loosePillsAdded: effectiveLoosePillsAdded, + totalPillsAdded, + refillDate: refill.refillDate, + }, + newStock: { + packCount: newPackCount, + looseTablets: newLooseTablets, + totalPills: newTotalPills, + }, + prescription: { + used: usePrescription, + remainingRefills: newRemainingRefills, + authorizedRefills: med.prescriptionAuthorizedRefills ?? null, + lowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1, + enabled: med.prescriptionEnabled ?? false, + }, + }; + } + ); // GET /medications/:id/refills - Get refill history for a medication - app.get<{ Params: { id: string } }>("/medications/:id/refills", async (req, reply) => { - const medId = Number(req.params.id); - if (Number.isNaN(medId)) return reply.badRequest("Invalid medication id"); + app.get<{ Params: { id: string } }>( + "/medications/:id/refills", + { + schema: { + params: idParamsSchema, + response: { + 200: { type: "array", items: refillHistoryItemSchema }, + 400: genericErrorSchema, + 401: genericErrorSchema, + 404: genericErrorSchema, + }, + }, + }, + async (req, reply) => { + const medId = Number(req.params.id); + if (Number.isNaN(medId)) return reply.badRequest("Invalid medication id"); - const userId = await getUserId(req, reply); + const userId = await getUserId(req, reply); - // Verify ownership - const [med] = await db - .select() - .from(medications) - .where(and(eq(medications.id, medId), eq(medications.userId, userId))); - if (!med) return reply.notFound("Medication not found"); + // Verify ownership + const [med] = await db + .select() + .from(medications) + .where(and(eq(medications.id, medId), eq(medications.userId, userId))); + if (!med) return reply.notFound("Medication not found"); - // Get refill history, newest first - const refills = await db - .select() - .from(refillHistory) - .where(and(eq(refillHistory.medicationId, medId), eq(refillHistory.userId, userId))) - .orderBy(desc(refillHistory.refillDate)); + // Get refill history, newest first + const refills = await db + .select() + .from(refillHistory) + .where(and(eq(refillHistory.medicationId, medId), eq(refillHistory.userId, userId))) + .orderBy(desc(refillHistory.refillDate)); - const packageType = normalizePackageType(med.packageType); - const isBottle = packageType === "bottle"; - const isAmountBased = isAmountBasedPackageType(packageType); - const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister; + const packageType = normalizePackageType(med.packageType); + const isBottle = packageType === "bottle"; + const isAmountBased = isAmountBasedPackageType(packageType); + const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister; - return refills.map((r) => ({ - id: r.id, - packsAdded: r.packsAdded, - loosePillsAdded: r.loosePillsAdded, - totalPillsAdded: isAmountBased ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded, - usedPrescription: r.usedPrescription ?? false, - refillDate: r.refillDate, - })); - }); + return refills.map((r) => ({ + id: r.id, + packsAdded: r.packsAdded, + loosePillsAdded: r.loosePillsAdded, + totalPillsAdded: isAmountBased ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded, + usedPrescription: r.usedPrescription ?? false, + refillDate: r.refillDate, + })); + } + ); } diff --git a/backend/src/routes/report.ts b/backend/src/routes/report.ts index 0d107de..efa75f6 100644 --- a/backend/src/routes/report.ts +++ b/backend/src/routes/report.ts @@ -6,12 +6,58 @@ import { doseTracking, medications, refillHistory } from "../db/schema.js"; import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; import { env } from "../plugins/env.js"; import type { AuthUser } from "../types/fastify.js"; -import { applyOpenApiRouteStandards } from "../utils/openapi-route-standards.js"; +import { + applyOpenApiRouteStandards, + genericErrorSchema, + validationErrorSchema, +} from "../utils/openapi-route-standards.js"; const reportDataSchema = z.object({ medicationIds: z.array(z.number().int().positive()).min(1).max(100), }); +const reportDataBodyOpenApiSchema = { + type: "object", + required: ["medicationIds"], + properties: { + medicationIds: { + type: "array", + minItems: 1, + maxItems: 100, + items: { type: "integer", minimum: 1 }, + }, + }, + example: { + medicationIds: [1, 3, 5], + }, +} as const; + +const reportDataResponseSchema = { + type: "object", + additionalProperties: { + type: "object", + properties: { + dosesTaken: { type: "integer" }, + automaticDosesTaken: { type: "integer" }, + dosesDismissed: { type: "integer" }, + firstDoseAt: { type: "string" }, + lastDoseAt: { type: "string" }, + refills: { + type: "array", + items: { + type: "object", + properties: { + packsAdded: { type: "integer" }, + loosePillsAdded: { type: "integer" }, + usedPrescription: { type: "boolean" }, + refillDate: { type: "string", format: "date-time" }, + }, + }, + }, + }, + }, +} as const; + export async function reportRoutes(app: FastifyInstance) { app.addHook("preHandler", requireAuth); applyOpenApiRouteStandards(app, { tag: "report", protectedByDefault: true }); @@ -29,90 +75,104 @@ export async function reportRoutes(app: FastifyInstance) { } // POST /medications/report-data - Get aggregated dose/refill data for report generation - app.post("/medications/report-data", async (req, reply) => { - const parsed = reportDataSchema.safeParse(req.body); - if (!parsed.success) return reply.status(400).send(parsed.error.format()); + app.post( + "/medications/report-data", + { + schema: { + body: reportDataBodyOpenApiSchema, + response: { + 200: reportDataResponseSchema, + 400: { anyOf: [genericErrorSchema, validationErrorSchema] }, + 401: genericErrorSchema, + 403: genericErrorSchema, + }, + }, + }, + async (req, reply) => { + const parsed = reportDataSchema.safeParse(req.body); + if (!parsed.success) return reply.status(400).send(parsed.error.format()); - const userId = await getUserId(req, reply); - const { medicationIds } = parsed.data; + const userId = await getUserId(req, reply); + const { medicationIds } = parsed.data; - // Verify all medications belong to this user - const userMeds = await db.select({ id: medications.id }).from(medications).where(eq(medications.userId, userId)); - const userMedIds = new Set(userMeds.map((m) => m.id)); + // Verify all medications belong to this user + const userMeds = await db.select({ id: medications.id }).from(medications).where(eq(medications.userId, userId)); + const userMedIds = new Set(userMeds.map((m) => m.id)); - for (const id of medicationIds) { - if (!userMedIds.has(id)) { - return reply.status(403).send({ error: "Access denied to medication" }); + for (const id of medicationIds) { + if (!userMedIds.has(id)) { + return reply.status(403).send({ error: "Access denied to medication" }); + } } - } - // Fetch dose tracking for all requested medications - // doseId format: "{medicationId}-{blisterIndex}-{dateMs}" or "{medicationId}-{blisterIndex}-{dateMs}-{takenBy}" - const allDoses = await db - .select({ - doseId: doseTracking.doseId, - takenAt: doseTracking.takenAt, - dismissed: doseTracking.dismissed, - takenSource: doseTracking.takenSource, - }) - .from(doseTracking) - .where(eq(doseTracking.userId, userId)); + // Fetch dose tracking for all requested medications + // doseId format: "{medicationId}-{blisterIndex}-{dateMs}" or "{medicationId}-{blisterIndex}-{dateMs}-{takenBy}" + const allDoses = await db + .select({ + doseId: doseTracking.doseId, + takenAt: doseTracking.takenAt, + dismissed: doseTracking.dismissed, + takenSource: doseTracking.takenSource, + }) + .from(doseTracking) + .where(eq(doseTracking.userId, userId)); - // Group doses by medication ID - const dosesByMed = new Map(); - for (const dose of allDoses) { - const medId = Number.parseInt(dose.doseId.split("-")[0], 10); - if (Number.isNaN(medId) || !medicationIds.includes(medId)) continue; - if (!dosesByMed.has(medId)) dosesByMed.set(medId, []); - dosesByMed.get(medId)!.push({ - takenAt: dose.takenAt, - dismissed: dose.dismissed, - takenSource: dose.takenSource ?? "manual", - }); - } - - // Fetch refill history for requested medications - const result: Record< - number, - { - dosesTaken: number; - automaticDosesTaken: number; - dosesDismissed: number; - firstDoseAt: string | null; - lastDoseAt: string | null; - refills: { packsAdded: number; loosePillsAdded: number; usedPrescription: boolean; refillDate: string }[]; + // Group doses by medication ID + const dosesByMed = new Map(); + for (const dose of allDoses) { + const medId = Number.parseInt(dose.doseId.split("-")[0], 10); + if (Number.isNaN(medId) || !medicationIds.includes(medId)) continue; + if (!dosesByMed.has(medId)) dosesByMed.set(medId, []); + dosesByMed.get(medId)!.push({ + takenAt: dose.takenAt, + dismissed: dose.dismissed, + takenSource: dose.takenSource ?? "manual", + }); } - > = {}; - for (const medId of medicationIds) { - const doses = dosesByMed.get(medId) ?? []; - const takenDoses = doses.filter((d) => !d.dismissed); - const automaticTakenDoses = takenDoses.filter((d) => d.takenSource === "automatic"); - const dismissedDoses = doses.filter((d) => d.dismissed); + // Fetch refill history for requested medications + const result: Record< + number, + { + dosesTaken: number; + automaticDosesTaken: number; + dosesDismissed: number; + firstDoseAt: string | null; + lastDoseAt: string | null; + refills: { packsAdded: number; loosePillsAdded: number; usedPrescription: boolean; refillDate: string }[]; + } + > = {}; - const sortedTaken = takenDoses.map((d) => d.takenAt.getTime()).sort((a, b) => a - b); + for (const medId of medicationIds) { + const doses = dosesByMed.get(medId) ?? []; + const takenDoses = doses.filter((d) => !d.dismissed); + const automaticTakenDoses = takenDoses.filter((d) => d.takenSource === "automatic"); + const dismissedDoses = doses.filter((d) => d.dismissed); - // Get refills for this medication scoped to the authenticated user. - const refills = await db - .select() - .from(refillHistory) - .where(and(eq(refillHistory.medicationId, medId), eq(refillHistory.userId, userId))); + const sortedTaken = takenDoses.map((d) => d.takenAt.getTime()).sort((a, b) => a - b); - result[medId] = { - dosesTaken: takenDoses.length, - automaticDosesTaken: automaticTakenDoses.length, - dosesDismissed: dismissedDoses.length, - firstDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[0]).toISOString() : null, - lastDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[sortedTaken.length - 1]).toISOString() : null, - refills: refills.map((r) => ({ - packsAdded: r.packsAdded, - loosePillsAdded: r.loosePillsAdded, - usedPrescription: r.usedPrescription ?? false, - refillDate: r.refillDate instanceof Date ? r.refillDate.toISOString() : String(r.refillDate), - })), - }; + // Get refills for this medication scoped to the authenticated user. + const refills = await db + .select() + .from(refillHistory) + .where(and(eq(refillHistory.medicationId, medId), eq(refillHistory.userId, userId))); + + result[medId] = { + dosesTaken: takenDoses.length, + automaticDosesTaken: automaticTakenDoses.length, + dosesDismissed: dismissedDoses.length, + firstDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[0]).toISOString() : null, + lastDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[sortedTaken.length - 1]).toISOString() : null, + refills: refills.map((r) => ({ + packsAdded: r.packsAdded, + loosePillsAdded: r.loosePillsAdded, + usedPrescription: r.usedPrescription ?? false, + refillDate: r.refillDate instanceof Date ? r.refillDate.toISOString() : String(r.refillDate), + })), + }; + } + + return result; } - - return result; - }); + ); } diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 53ea015..a1ebd24 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -481,6 +481,33 @@ export async function settingsRoutes(app: FastifyInstance) { shareScheduleTodayOnly: { type: "boolean" }, swapDashboardMainSections: { type: "boolean" }, }, + example: { + emailEnabled: true, + notificationEmail: "daniel@example.com", + reminderDaysBefore: 7, + repeatDailyReminders: true, + lowStockDays: 14, + normalStockDays: 30, + highStockDays: 90, + shoutrrrEnabled: false, + shoutrrrUrl: "", + emailStockReminders: true, + emailIntakeReminders: true, + emailPrescriptionReminders: true, + shoutrrrStockReminders: false, + shoutrrrIntakeReminders: false, + shoutrrrPrescriptionReminders: false, + skipRemindersForTakenDoses: true, + repeatRemindersEnabled: true, + reminderRepeatIntervalMinutes: 30, + maxNaggingReminders: 5, + language: "en", + stockCalculationMode: "automatic", + shareStockStatus: true, + upcomingTodayOnly: false, + shareScheduleTodayOnly: false, + swapDashboardMainSections: false, + }, }, response: { 200: { type: "object", properties: { success: { type: "boolean" } } }, @@ -560,6 +587,9 @@ export async function settingsRoutes(app: FastifyInstance) { properties: { language: { type: "string", enum: ["en", "de"] }, }, + example: { + language: "de", + }, }, response: { 200: { type: "object", properties: { success: { type: "boolean" } } }, @@ -607,6 +637,9 @@ export async function settingsRoutes(app: FastifyInstance) { properties: { email: { type: "string", format: "email" }, }, + example: { + email: "daniel@example.com", + }, }, response: { 200: { @@ -714,6 +747,9 @@ export async function settingsRoutes(app: FastifyInstance) { properties: { url: { type: "string" }, }, + example: { + url: "ntfy://user:token@push.example.com/medassist", + }, }, response: { 200: { diff --git a/backend/src/routes/share.ts b/backend/src/routes/share.ts index 1942e0f..c7c21a1 100644 --- a/backend/src/routes/share.ts +++ b/backend/src/routes/share.ts @@ -8,7 +8,12 @@ import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; import { env } from "../plugins/env.js"; import { buildSharedMedicationOverview } from "../services/coverage.js"; import type { AuthUser } from "../types/fastify.js"; -import { applyOpenApiRouteStandards } from "../utils/openapi-route-standards.js"; +import { + applyOpenApiRouteStandards, + genericErrorSchema, + tokenParamsSchema, + validationErrorSchema, +} from "../utils/openapi-route-standards.js"; import { isAmountBasedPackageType, normalizePackageType } from "../utils/package-profiles.js"; import { getAllTakenByForMedication, @@ -32,6 +37,62 @@ const protectedEndpointSecurity: ReadonlyArray const shareTokenPattern = /^[a-f0-9]{16}$/; +const createShareBodyOpenApiSchema = { + type: "object", + properties: { + takenBy: { type: "string" }, + scheduleDays: { type: "integer", minimum: 1, maximum: 365, default: 30 }, + }, + example: { + takenBy: "Daniel", + scheduleDays: 14, + }, +} as const; + +const shareReadResponseSchema = { + type: "object", + properties: { + takenBy: { type: "string" }, + sharedBy: { type: "string" }, + scheduleDays: { type: "integer" }, + medications: { type: "array", items: { type: "object", additionalProperties: true } }, + stockThresholds: { type: "object", additionalProperties: { type: "number" } }, + stockCalculationMode: { type: "string", enum: ["automatic", "manual"] }, + shareStockStatus: { type: "boolean" }, + upcomingTodayOnly: { type: "boolean" }, + shareScheduleTodayOnly: { type: "boolean" }, + }, +} as const; + +const shareExpiredResponseSchema = { + type: "object", + properties: { + error: { type: "string" }, + code: { type: "string" }, + ownerUsername: { type: "string" }, + takenBy: { type: "string" }, + expiredAt: { type: "string", format: "date-time" }, + }, +} as const; + +const shareOverviewExpiredResponseSchema = { + type: "object", + properties: { + error: { type: "string" }, + expiredAt: { type: "string", format: "date-time" }, + }, +} as const; + +const shareOverviewResponseSchema = { + type: "object", + properties: { + takenBy: { type: "string" }, + sharedBy: { type: "string" }, + generatedAt: { type: "string", format: "date-time" }, + medications: { type: "array", items: { type: "object", additionalProperties: true } }, + }, +} as const; + function maskToken(token: string): string { if (token.length <= 8) return token; return `${token.slice(0, 4)}...${token.slice(-4)}`; @@ -69,6 +130,14 @@ export async function shareRoutes(app: FastifyInstance) { app.get<{ Params: { token: string } }>( "/share/:token", { + schema: { + params: tokenParamsSchema, + response: { + 200: shareReadResponseSchema, + 404: genericErrorSchema, + 410: shareExpiredResponseSchema, + }, + }, config: { rateLimit: { max: 60, @@ -198,6 +267,14 @@ export async function shareRoutes(app: FastifyInstance) { app.get<{ Params: { token: string } }>( "/share/:token/overview", { + schema: { + params: tokenParamsSchema, + response: { + 200: shareOverviewResponseSchema, + 404: genericErrorSchema, + 410: shareOverviewExpiredResponseSchema, + }, + }, config: { rateLimit: { max: 60, @@ -268,7 +345,27 @@ export async function shareRoutes(app: FastifyInstance) { // --------------------------------------------------------------------------- app.post<{ Body: z.infer }>( "/share", - { preHandler: requireAuth, schema: { tags: ["share"], security: protectedEndpointSecurity } }, + { + preHandler: requireAuth, + schema: { + tags: ["share"], + security: protectedEndpointSecurity, + body: createShareBodyOpenApiSchema, + response: { + 200: { + type: "object", + properties: { + reused: { type: "boolean" }, + token: { type: "string" }, + shareUrl: { type: "string" }, + expiresAt: { type: ["string", "null"] }, + }, + }, + 400: { anyOf: [genericErrorSchema, validationErrorSchema] }, + 401: genericErrorSchema, + }, + }, + }, async (request, reply) => { const userId = await getUserId(request, reply); @@ -351,7 +448,22 @@ export async function shareRoutes(app: FastifyInstance) { // --------------------------------------------------------------------------- app.get( "/share/people", - { preHandler: requireAuth, schema: { tags: ["share"], security: protectedEndpointSecurity } }, + { + preHandler: requireAuth, + schema: { + tags: ["share"], + security: protectedEndpointSecurity, + response: { + 200: { + type: "object", + properties: { + people: { type: "array", items: { type: "string" } }, + }, + }, + 401: genericErrorSchema, + }, + }, + }, async (request, reply) => { const userId = await getUserId(request, reply); diff --git a/backend/src/test/auth.test.ts b/backend/src/test/auth.test.ts index e005368..fe3514f 100644 --- a/backend/src/test/auth.test.ts +++ b/backend/src/test/auth.test.ts @@ -8,6 +8,7 @@ import sensible from "@fastify/sensible"; import type { Client } from "@libsql/client"; import Fastify, { type FastifyInstance } from "fastify"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js"; // Use vi.hoisted to create the db BEFORE mocks are set up const { testClient, testDb } = vi.hoisted(() => { @@ -97,7 +98,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => { beforeAll(async () => { await createSchema(testClient); - app = Fastify({ logger: false }); + app = Fastify({ logger: false, ajv: documentationSchemaAjv }); await app.register(sensible); await app.register(cookie, { secret: "test-cookie-secret-12345" }); diff --git a/backend/src/test/business-authz-real.test.ts b/backend/src/test/business-authz-real.test.ts index 64f797a..a268a47 100644 --- a/backend/src/test/business-authz-real.test.ts +++ b/backend/src/test/business-authz-real.test.ts @@ -7,6 +7,7 @@ import { migrate } from "drizzle-orm/libsql/migrator"; import Fastify, { type FastifyInstance } from "fastify"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { runAlterMigrations } from "../db/db-utils.js"; +import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js"; const { testClient, testDb, mockedEnv } = vi.hoisted(() => { const { createClient } = require("@libsql/client"); @@ -226,7 +227,7 @@ describe("Real business route authz contracts", () => { await migrate(testDb, { migrationsFolder }); await runAlterMigrations(testClient); - app = Fastify({ logger: false }); + app = Fastify({ logger: false, ajv: documentationSchemaAjv }); await app.register(sensible); await app.register(cookie, { secret: "test-cookie-secret" }); await app.register(jwt, { diff --git a/backend/src/test/doses.test.ts b/backend/src/test/doses.test.ts index f44dd1a..f9a146b 100644 --- a/backend/src/test/doses.test.ts +++ b/backend/src/test/doses.test.ts @@ -6,6 +6,7 @@ import { migrate } from "drizzle-orm/libsql/migrator"; import Fastify, { type FastifyInstance } from "fastify"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { runAlterMigrations } from "../db/db-utils.js"; +import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js"; const { testClient, testDb, mockedEnv } = vi.hoisted(() => { const { createClient } = require("@libsql/client"); @@ -104,7 +105,7 @@ describe("Dose Tracking API", () => { await migrate(testDb, { migrationsFolder }); await runAlterMigrations(testClient); - app = Fastify({ logger: false }); + app = Fastify({ logger: false, ajv: documentationSchemaAjv }); await app.register(cookie, { secret: "test-cookie-secret" }); await app.register(jwt, { secret: "test-jwt-secret", diff --git a/backend/src/test/e2e-routes.test.ts b/backend/src/test/e2e-routes.test.ts index 8bd00fb..d750607 100644 --- a/backend/src/test/e2e-routes.test.ts +++ b/backend/src/test/e2e-routes.test.ts @@ -10,6 +10,7 @@ import sensible from "@fastify/sensible"; import type { Client } from "@libsql/client"; import Fastify, { type FastifyInstance } from "fastify"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js"; // Use vi.hoisted to create the db BEFORE mocks are set up const { testClient, testDb } = vi.hoisted(() => { @@ -247,7 +248,7 @@ describe("E2E Tests with Real Routes", () => { await createSchema(testClient); // Build app with real routes - app = Fastify({ logger: false }); + app = Fastify({ logger: false, ajv: documentationSchemaAjv }); await app.register(sensible); await app.register(cookie, { secret: "test-cookie-secret" }); diff --git a/backend/src/test/integration.test.ts b/backend/src/test/integration.test.ts index 0c0cddf..fae8546 100644 --- a/backend/src/test/integration.test.ts +++ b/backend/src/test/integration.test.ts @@ -10,6 +10,7 @@ import sensible from "@fastify/sensible"; import type { Client } from "@libsql/client"; import Fastify, { type FastifyInstance } from "fastify"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js"; // Use vi.hoisted to create the db BEFORE mocks are set up const { testClient, testDb } = vi.hoisted(() => { @@ -203,7 +204,7 @@ describe("Integration Tests", () => { beforeAll(async () => { await createSchema(testClient); - app = Fastify({ logger: false }); + app = Fastify({ logger: false, ajv: documentationSchemaAjv }); await app.register(sensible); await app.register(cookie, { secret: "test-cookie-secret" }); await app.register(jwt, { diff --git a/backend/src/test/oidc.test.ts b/backend/src/test/oidc.test.ts index 117011a..f46acd8 100644 --- a/backend/src/test/oidc.test.ts +++ b/backend/src/test/oidc.test.ts @@ -1,6 +1,7 @@ import cookie from "@fastify/cookie"; import Fastify from "fastify"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js"; type OidcMocks = { discovery: ReturnType; @@ -54,7 +55,7 @@ async function buildOidcApp(envOverrides: Record) { const { oidcRoutes } = await import("../routes/oidc.js"); - const app = Fastify({ logger: false }); + const app = Fastify({ logger: false, ajv: documentationSchemaAjv }); await app.register(cookie, { secret: "test-cookie-secret" }); app.decorate("config", { accessSecret: "test-jwt-secret-12345", diff --git a/backend/src/test/planner.test.ts b/backend/src/test/planner.test.ts index f032cb1..9262ecc 100644 --- a/backend/src/test/planner.test.ts +++ b/backend/src/test/planner.test.ts @@ -1,6 +1,7 @@ import type { Client } from "@libsql/client"; import Fastify, { type FastifyInstance } from "fastify"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js"; // Create test database and mocks before anything else (hoisted) const { @@ -214,7 +215,7 @@ describe("Planner Routes", () => { args: [], }); - app = Fastify({ logger: false }); + app = Fastify({ logger: false, ajv: documentationSchemaAjv }); await app.register(plannerRoutes); await app.ready(); diff --git a/backend/src/test/routes-real.test.ts b/backend/src/test/routes-real.test.ts index e62bd18..32ab39d 100644 --- a/backend/src/test/routes-real.test.ts +++ b/backend/src/test/routes-real.test.ts @@ -4,6 +4,7 @@ import { migrate } from "drizzle-orm/libsql/migrator"; import Fastify, { type FastifyInstance } from "fastify"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { runAlterMigrations } from "../db/db-utils.js"; +import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js"; const { testClient, testDb, mockedEnv, nodemailerSendMail, fetchMock } = vi.hoisted(() => { const { createClient } = require("@libsql/client"); @@ -108,7 +109,7 @@ describe("Real route coverage: settings/export/report", () => { beforeAll(async () => { await migrate(testDb, { migrationsFolder }); await runAlterMigrations(testClient); - app = Fastify({ logger: false }); + app = Fastify({ logger: false, ajv: documentationSchemaAjv }); await app.register(settingsRoutes); await app.register(exportRoutes); await app.register(reportRoutes); diff --git a/backend/src/test/server.test.ts b/backend/src/test/server.test.ts index 8b37e58..3a6e899 100644 --- a/backend/src/test/server.test.ts +++ b/backend/src/test/server.test.ts @@ -6,6 +6,7 @@ import cors from "@fastify/cors"; import sensible from "@fastify/sensible"; import Fastify, { type FastifyInstance } from "fastify"; import { afterEach, describe, expect, it } from "vitest"; +import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js"; // Import from utils to avoid index.ts import side effects (server start) import { @@ -197,6 +198,7 @@ describe("Server Bootstrap", () => { logger: { level: "silent", // Disable logging for tests }, + ajv: documentationSchemaAjv, }); expect(app).toBeDefined(); @@ -206,7 +208,7 @@ describe("Server Bootstrap", () => { }); it("should register sensible plugin", async () => { - const app = Fastify({ logger: false }); + const app = Fastify({ logger: false, ajv: documentationSchemaAjv }); await app.register(sensible); // Sensible adds error helpers @@ -219,7 +221,7 @@ describe("Server Bootstrap", () => { it("should register cors plugin with multiple origins", async () => { const origins = ["http://localhost:5173", "http://localhost:4173"]; - const app = Fastify({ logger: false }); + const app = Fastify({ logger: false, ajv: documentationSchemaAjv }); await app.register(cors, { origin: origins, credentials: true }); // Add a test route @@ -243,7 +245,7 @@ describe("Server Bootstrap", () => { }); it("should register cookie plugin", async () => { - const app = Fastify({ logger: false }); + const app = Fastify({ logger: false, ajv: documentationSchemaAjv }); await app.register(cookie, { secret: "test-cookie-secret" }); // Add a test route that sets a cookie @@ -267,7 +269,7 @@ describe("Server Bootstrap", () => { describe("Config Decorator", () => { it("should create config with auth settings", async () => { - const app = Fastify({ logger: false }); + const app = Fastify({ logger: false, ajv: documentationSchemaAjv }); const accessTtlMinutes = 15; const refreshTtlDays = 7; @@ -369,7 +371,7 @@ describe("Server Bootstrap", () => { describe("Route Registration", () => { it("should register multiple route plugins", async () => { - const app = Fastify({ logger: false }); + const app = Fastify({ logger: false, ajv: documentationSchemaAjv }); // Mock route plugins const healthRoutes = async (app: FastifyInstance) => { @@ -402,7 +404,7 @@ describe("Server Bootstrap", () => { describe("Server Startup", () => { it("should listen on specified port", async () => { - const app = Fastify({ logger: false }); + const app = Fastify({ logger: false, ajv: documentationSchemaAjv }); app.get("/test", async () => ({ ok: true })); @@ -415,7 +417,7 @@ describe("Server Bootstrap", () => { }); it("should handle listen errors gracefully", async () => { - const app = Fastify({ logger: false }); + const app = Fastify({ logger: false, ajv: documentationSchemaAjv }); // Try to listen on an invalid port await expect(app.listen({ port: -1, host: "127.0.0.1" })).rejects.toThrow(); diff --git a/backend/src/test/settings-auth-security.test.ts b/backend/src/test/settings-auth-security.test.ts index 8c7a198..04a57a1 100644 --- a/backend/src/test/settings-auth-security.test.ts +++ b/backend/src/test/settings-auth-security.test.ts @@ -7,6 +7,7 @@ import { migrate } from "drizzle-orm/libsql/migrator"; import Fastify, { type FastifyInstance } from "fastify"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { runAlterMigrations } from "../db/db-utils.js"; +import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js"; const { testClient, testDb, mockedEnv, nodemailerSendMail } = vi.hoisted(() => { const { createClient } = require("@libsql/client"); @@ -115,7 +116,7 @@ describe("Settings and API key security contracts", () => { await migrate(testDb, { migrationsFolder }); await runAlterMigrations(testClient); - app = Fastify({ logger: false }); + app = Fastify({ logger: false, ajv: documentationSchemaAjv }); await app.register(sensible); await app.register(cookie, { secret: "test-cookie-secret" }); await app.register(jwt, { diff --git a/backend/src/test/setup.ts b/backend/src/test/setup.ts index eb292a1..ba996de 100644 --- a/backend/src/test/setup.ts +++ b/backend/src/test/setup.ts @@ -13,6 +13,7 @@ import { type Client, createClient } from "@libsql/client"; import { drizzle } from "drizzle-orm/libsql"; import { migrate } from "drizzle-orm/libsql/migrator"; import Fastify, { type FastifyInstance } from "fastify"; +import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js"; // Get migrations folder path const __filename = fileURLToPath(import.meta.url); @@ -44,7 +45,7 @@ export async function buildTestApp(): Promise { await runTestMigrations(client); // Create Fastify app with minimal plugins - const app = Fastify({ logger: false }); + const app = Fastify({ logger: false, ajv: documentationSchemaAjv }); await app.register(sensible); await app.register(cookie, { secret: "test-cookie-secret" }); diff --git a/backend/src/test/stock-semantics-parity.test.ts b/backend/src/test/stock-semantics-parity.test.ts index 881d6ca..eb7e2c4 100644 --- a/backend/src/test/stock-semantics-parity.test.ts +++ b/backend/src/test/stock-semantics-parity.test.ts @@ -4,6 +4,7 @@ import { migrate } from "drizzle-orm/libsql/migrator"; import Fastify, { type FastifyInstance } from "fastify"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { runAlterMigrations } from "../db/db-utils.js"; +import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js"; const { testClient, testDb, mockedEnv } = vi.hoisted(() => { const { createClient } = require("@libsql/client"); @@ -173,7 +174,7 @@ describe("Stock semantics parity (planner usage vs scheduler)", () => { beforeAll(async () => { await migrate(testDb, { migrationsFolder }); await runAlterMigrations(testClient); - app = Fastify({ logger: false }); + app = Fastify({ logger: false, ajv: documentationSchemaAjv }); await app.register(medicationRoutes); await app.ready(); }); diff --git a/backend/src/utils/documentation-schema-keywords.ts b/backend/src/utils/documentation-schema-keywords.ts new file mode 100644 index 0000000..b71025e --- /dev/null +++ b/backend/src/utils/documentation-schema-keywords.ts @@ -0,0 +1,10 @@ +import type { Plugin } from "ajv"; + +export const registerDocumentationSchemaKeywords: Plugin = (ajv) => { + ajv.addKeyword({ keyword: "example", valid: true }); + return ajv; +}; + +export const documentationSchemaAjv = { + plugins: [registerDocumentationSchemaKeywords], +}; diff --git a/backend/src/utils/openapi-route-standards.ts b/backend/src/utils/openapi-route-standards.ts index e67db0c..82b9046 100644 --- a/backend/src/utils/openapi-route-standards.ts +++ b/backend/src/utils/openapi-route-standards.ts @@ -4,6 +4,50 @@ type SecurityEntry = Readonly>; const defaultProtectedSecurity: readonly SecurityEntry[] = [{ bearerAuth: [] }, { cookieAuth: [] }]; +export const genericErrorSchema = { + type: "object", + properties: { + error: { type: "string" }, + code: { type: "string" }, + }, +} as const; + +export const validationErrorSchema = { + type: "object", + additionalProperties: true, +} as const; + +export const idParamsSchema = { + type: "object", + required: ["id"], + properties: { + id: { type: "string", pattern: "^\\d+$" }, + }, +} as const; + +export const tokenParamsSchema = { + type: "object", + required: ["token"], + properties: { + token: { type: "string", minLength: 1 }, + }, +} as const; + +export const successResponseSchema = { + type: "object", + properties: { + success: { type: "boolean" }, + }, +} as const; + +export const messageResponseSchema = { + type: "object", + properties: { + success: { type: "boolean" }, + message: { type: "string" }, + }, +} as const; + export type OpenApiRouteStandardsOptions = { tag: string; protectedByDefault: boolean;