import { randomBytes } from "node:crypto"; import { eq } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import { db } from "../db/client.js"; import { medications, shareTokens, userSettings, users } 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 { getAllTakenByForMedication, parseIntakesJson, parseTakenByJson, personTakesMedication, } from "../utils/scheduler-utils.js"; // Share token validity: 1 year in milliseconds const SHARE_TOKEN_VALIDITY_MS = 365 * 24 * 60 * 60 * 1000; // ============================================================================= // Validation Schemas // ============================================================================= const createShareSchema = z.object({ takenBy: z.string().min(1, "takenBy is required"), scheduleDays: z.number().int().min(1).max(365).default(30), }); // 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) { // --------------------------------------------------------------------------- // GET /share/:token - PUBLIC: Get shared schedule by token // --------------------------------------------------------------------------- app.get<{ Params: { token: string } }>("/share/:token", async (request, reply) => { const { token } = request.params; // Find share token const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token)); if (!share) { 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()) { // 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(eq(medications.userId, share.userId)); // 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 = (med.packageType ?? "blister") === "bottle" ? 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: med.packageType ?? "blister", 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, }; }); return { takenBy: share.takenBy, sharedBy: owner?.username ?? null, scheduleDays: share.scheduleDays, medications: medicationsWithBlisters, 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", shareStockStatus: settings?.shareStockStatus ?? true, }; }); // --------------------------------------------------------------------------- // POST /share - PROTECTED: Create a new share link // --------------------------------------------------------------------------- app.post<{ Body: z.infer }>( "/share", { preHandler: requireAuth }, 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.errors[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(eq(medications.userId, userId)); 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", }); } // Generate unique token (8 bytes = 16 hex chars) const token = randomBytes(8).toString("hex"); // Set expiration date (1 year from now) const expiresAt = new Date(Date.now() + SHARE_TOKEN_VALIDITY_MS); // Create share token await db.insert(shareTokens).values({ userId: userId, token, takenBy, scheduleDays, expiresAt, }); return { token, shareUrl: `/share/${token}`, expiresAt: expiresAt.toISOString(), }; } ); // --------------------------------------------------------------------------- // GET /share/people - PROTECTED: Get list of unique takenBy values // --------------------------------------------------------------------------- app.get("/share/people", { preHandler: requireAuth }, 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(eq(medications.userId, userId)); // 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() }; }); }