import { randomBytes } from "node:crypto"; import { and, eq } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import { db } from "../db/client.js"; import { doseTracking, medications, shareTokens, userSettings, users } from "../db/schema.js"; 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, genericErrorSchema, tokenParamsSchema, validationErrorSchema, } from "../utils/openapi-route-standards.js"; import { isAmountBasedPackageType, normalizePackageType } from "../utils/package-profiles.js"; import { getAllTakenByForMedication, parseIntakesJson, parseTakenByJson, personTakesMedication, } from "../utils/scheduler-utils.js"; // ============================================================================= // Validation Schemas // ============================================================================= const createShareSchema = z.object({ takenBy: z.string().min(1, "takenBy is required"), scheduleDays: z.number().int().min(1).max(365).default(30), }); const protectedEndpointSecurity: ReadonlyArray> = [ { bearerAuth: [] }, { cookieAuth: [] }, ]; 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 } }, shareMedicationOverview: { type: "boolean" }, medicationOverview: { anyOf: [{ type: "array", items: { type: "object", additionalProperties: true } }, { type: "null" }], }, stockThresholds: { type: "object", additionalProperties: { type: "number" } }, stockCalculationMode: { type: "string", enum: ["automatic", "manual"] }, 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; // Helper to get user ID from request // Returns anonymous user ID when auth is disabled async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise { // If auth is disabled, use the anonymous user if (!env.AUTH_ENABLED) { return getAnonymousUserId(); } const authUser = request.user as unknown as AuthUser | null; if (!authUser) { reply.status(401).send({ error: "Not authenticated" }); throw new Error("AUTH_REQUIRED"); } return authUser.id; } // ============================================================================= // Share Routes // ============================================================================= export async function shareRoutes(app: FastifyInstance) { applyOpenApiRouteStandards(app, { tag: "share", protectedByDefault: false, protectedPaths: [/^\/share$/, /^\/share\/people$/], }); // --------------------------------------------------------------------------- // GET /share/:token - PUBLIC: Get shared schedule by token // --------------------------------------------------------------------------- app.get<{ Params: { token: string } }>( "/share/:token", { schema: { params: tokenParamsSchema, response: { 200: shareReadResponseSchema, 404: genericErrorSchema, 410: shareExpiredResponseSchema, }, }, config: { rateLimit: { max: 60, timeWindow: "1 minute", errorResponseBuilder: () => ({ error: "rate_limited" }), }, }, }, async (request, reply) => { const { token } = request.params; // Find share token const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token)); if (!share) { request.log.warn(`[Share] Invalid share token requested: token=${token}`); return reply.status(404).send({ error: "Share link not found", code: "NOT_FOUND", }); } // Check if token has expired if (share.expiresAt && share.expiresAt.getTime() < Date.now()) { request.log.warn( `[Share] Expired token requested: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}` ); // Get the username of the owner to show in the expired message const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId)); return reply.status(410).send({ error: "Share link has expired", code: "EXPIRED", ownerUsername: owner?.username ?? "the owner", takenBy: share.takenBy, expiredAt: share.expiresAt.toISOString(), }); } // Get user settings for stock thresholds const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, share.userId)); // Get the username of the owner who created this share link const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId)); // Get medications for this user filtered by takenBy (search in JSON array) // Use SQLite JSON function to check if takenBy is in the array const allMeds = await db .select() .from(medications) .where(and(eq(medications.userId, share.userId), eq(medications.isObsolete, false))); // Filter medications where takenBy matches either medication-level OR any intake-level takenBy const meds = allMeds.filter((med) => { const takenByArray = parseTakenByJson(med.takenByJson); const intakes = parseIntakesJson( med.intakesJson, { usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson }, med.intakeRemindersEnabled ?? false ); return personTakesMedication(share.takenBy, takenByArray, intakes); }); // Parse blisters and build schedule data const medicationsWithBlisters = meds.map((med) => { // Parse intakes from new format, falling back to legacy const intakes = parseIntakesJson( med.intakesJson, { usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson }, med.intakeRemindersEnabled ?? false ); // Convert to legacy blisters format for backward compat const blisters = intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start, })); // Parse takenBy JSON array const takenByArray = parseTakenByJson(med.takenByJson); const totalPills = isAmountBasedPackageType(med.packageType) ? med.looseTablets + (med.stockAdjustment ?? 0) : med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0); return { id: med.id, name: med.name, genericName: med.genericName, pillWeightMg: med.pillWeightMg, doseUnit: med.doseUnit ?? "mg", imageUrl: med.imageUrl, totalPills, packageType: normalizePackageType(med.packageType), packCount: med.packCount, blistersPerPack: med.blistersPerPack, looseTablets: med.looseTablets, pillsPerBlister: med.pillsPerBlister, takenBy: takenByArray, intakes, // New unified format with per-intake takenBy blisters, // Legacy format for backward compat dismissedUntil: med.dismissedUntil, updatedAt: med.updatedAt, // For filtering out doses from previous schedule configurations lastStockCorrectionAt: med.lastStockCorrectionAt?.getTime() ?? null, stockAdjustment: med.stockAdjustment ?? 0, }; }); const shareMedicationOverview = settings?.shareMedicationOverview ?? false; const medicationOverview = shareMedicationOverview ? buildSharedMedicationOverview({ medications: meds, doses: await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId)), thresholdDays: settings?.lowStockDays ?? 30, }) : null; return { takenBy: share.takenBy, sharedBy: owner?.username ?? null, scheduleDays: share.scheduleDays, medications: medicationsWithBlisters, shareMedicationOverview, medicationOverview, stockThresholds: { lowStockDays: settings?.lowStockDays ?? 30, normalStockDays: settings?.normalStockDays ?? 60, highStockDays: settings?.highStockDays ?? 90, reminderDaysBefore: settings?.reminderDaysBefore ?? 7, expiryWarningDays: settings?.expiryWarningDays ?? 90, }, stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic", upcomingTodayOnly: settings?.upcomingTodayOnly ?? false, shareScheduleTodayOnly: settings?.shareScheduleTodayOnly ?? false, }; } ); // --------------------------------------------------------------------------- // GET /share/:token/overview - PUBLIC: Read-only medication overview by token // --------------------------------------------------------------------------- app.get<{ Params: { token: string } }>( "/share/:token/overview", { schema: { params: tokenParamsSchema, response: { 200: shareOverviewResponseSchema, 404: genericErrorSchema, 410: shareOverviewExpiredResponseSchema, }, }, config: { rateLimit: { max: 60, timeWindow: "1 minute", errorResponseBuilder: () => ({ error: "rate_limited" }), }, }, }, async (request, reply) => { reply.header("Cache-Control", "no-store"); const { token } = request.params; if (!shareTokenPattern.test(token)) { request.log.warn(`[ShareOverview] Rejected invalid token format: token=${token}`); return reply.status(404).send({ error: "not_found" }); } const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token)); if (!share) { request.log.warn(`[ShareOverview] Unknown token requested: token=${token}`); return reply.status(404).send({ error: "not_found" }); } if (share.expiresAt && share.expiresAt.getTime() < Date.now()) { request.log.warn( `[ShareOverview] Expired token requested: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}` ); return reply.status(410).send({ error: "expired", expiredAt: share.expiresAt.toISOString(), }); } const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, share.userId)); const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId)); const allMeds = await db .select() .from(medications) .where(and(eq(medications.userId, share.userId), eq(medications.isObsolete, false))); const meds = allMeds.filter((med) => { const takenByArray = parseTakenByJson(med.takenByJson); const intakes = parseIntakesJson( med.intakesJson, { usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson }, med.intakeRemindersEnabled ?? false ); return personTakesMedication(share.takenBy, takenByArray, intakes); }); const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId)); const overview = buildSharedMedicationOverview({ medications: meds, doses, thresholdDays: settings?.lowStockDays ?? 30, }); return { takenBy: share.takenBy, sharedBy: owner?.username ?? null, generatedAt: new Date().toISOString(), medications: overview, }; } ); // --------------------------------------------------------------------------- // POST /share - PROTECTED: Create a new share link // --------------------------------------------------------------------------- app.post<{ Body: z.infer }>( "/share", { 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); const parsed = createShareSchema.safeParse(request.body); if (!parsed.success) { return reply.status(400).send({ error: parsed.error.issues[0]?.message ?? "Invalid input", code: "VALIDATION_ERROR", }); } const { takenBy, scheduleDays } = parsed.data; // Check if user has medications for this takenBy (search in both medication-level and intake-level) const allMeds = await db .select() .from(medications) .where(and(eq(medications.userId, userId), eq(medications.isObsolete, false))); const medsForPerson = allMeds.filter((med) => { const takenByArray = parseTakenByJson(med.takenByJson); const intakes = parseIntakesJson( med.intakesJson, { usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson }, med.intakeRemindersEnabled ?? false ); return personTakesMedication(takenBy, takenByArray, intakes); }); if (medsForPerson.length === 0) { return reply.status(400).send({ error: "No medications found for this person", code: "NO_MEDICATIONS", }); } // Keep exactly one active share link per person/user. // If a link already exists, return the same token and only update settings. const [existingShare] = await db .select() .from(shareTokens) .where(and(eq(shareTokens.userId, userId), eq(shareTokens.takenBy, takenBy))); if (existingShare) { await db.update(shareTokens).set({ scheduleDays, expiresAt: null }).where(eq(shareTokens.id, existingShare.id)); request.log.info( `[Share] Reused existing share token: token=${existingShare.token}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}` ); return { reused: true, token: existingShare.token, shareUrl: `/share/${existingShare.token}`, expiresAt: null, }; } const token = randomBytes(8).toString("hex"); await db.insert(shareTokens).values({ userId, token, takenBy, scheduleDays, expiresAt: null, }); request.log.info( `[Share] Created new share token: token=${token}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}` ); return { reused: false, token, shareUrl: `/share/${token}`, expiresAt: null, }; } ); // --------------------------------------------------------------------------- // GET /share/people - PROTECTED: Get list of unique takenBy values // --------------------------------------------------------------------------- app.get( "/share/people", { 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); // Get all unique takenBy values for this user (from both medication-level and intake-level) const meds = await db .select({ takenByJson: medications.takenByJson, intakesJson: medications.intakesJson, usageJson: medications.usageJson, everyJson: medications.everyJson, startJson: medications.startJson, intakeRemindersEnabled: medications.intakeRemindersEnabled, }) .from(medications) .where(and(eq(medications.userId, userId), eq(medications.isObsolete, false))); // Collect all unique person names from medication-level AND intake-level takenBy const allPeople = new Set(); for (const med of meds) { const takenByArray = parseTakenByJson(med.takenByJson); const intakes = parseIntakesJson( med.intakesJson, { usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson }, med.intakeRemindersEnabled ?? false ); const allForMed = getAllTakenByForMedication(takenByArray, intakes); for (const person of allForMed) { if (person) allPeople.add(person); } } return { people: [...allPeople].sort() }; } ); }