diff --git a/backend/src/routes/doses.ts b/backend/src/routes/doses.ts index cf6bd44..bc10151 100644 --- a/backend/src/routes/doses.ts +++ b/backend/src/routes/doses.ts @@ -2,10 +2,11 @@ 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, shareTokens } from "../db/schema.js"; +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 { parseIntakesJson, parseTakenByJson, personTakesMedication } from "../utils/scheduler-utils.js"; // ============================================================================= // Validation Schemas @@ -22,6 +23,13 @@ const dismissDosesSchema = z.object({ doseIds: z.array(z.string().min(1)).min(1, "At least one doseId is required"), }); +const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/; + +function maskToken(token: string): string { + if (token.length <= 8) return token; + return `${token.slice(0, 4)}...${token.slice(-4)}`; +} + // Helper to get user ID from request // Returns anonymous user ID when auth is disabled async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise { @@ -38,6 +46,91 @@ async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise< return authUser.id; } +type ParsedDoseId = { + medicationId: number; + intakeIndex: number; + timestampMs: number; + personSuffix: string | null; +}; + +function parseDoseId(doseId: string): ParsedDoseId | null { + const match = doseIdPattern.exec(doseId); + if (!match) return null; + + const medicationId = Number.parseInt(match[1], 10); + const intakeIndex = Number.parseInt(match[2], 10); + const timestampMs = Number.parseInt(match[3], 10); + const personSuffix = match[4] ? match[4].trim() : null; + + if (Number.isNaN(medicationId) || Number.isNaN(intakeIndex) || Number.isNaN(timestampMs) || intakeIndex < 0) { + return null; + } + + return { + medicationId, + intakeIndex, + timestampMs, + personSuffix, + }; +} + +async function getActiveShareToken(token: string): Promise<{ + share: typeof shareTokens.$inferSelect | null; + reason: "not_found" | "expired" | "ok"; +}> { + const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token)); + if (!share) return { share: null, reason: "not_found" }; + + if (share.expiresAt && share.expiresAt.getTime() < Date.now()) { + return { share: null, reason: "expired" }; + } + + return { share, reason: "ok" }; +} + +async function validateShareDoseId(share: typeof shareTokens.$inferSelect, doseId: string): Promise { + const parsedDose = parseDoseId(doseId); + if (!parsedDose) { + return false; + } + + const [medication] = await db + .select() + .from(medications) + .where(and(eq(medications.id, parsedDose.medicationId), eq(medications.userId, share.userId))); + + if (!medication) { + return false; + } + + const medTakenBy = parseTakenByJson(medication.takenByJson); + const intakes = parseIntakesJson( + medication.intakesJson, + { usageJson: medication.usageJson, everyJson: medication.everyJson, startJson: medication.startJson }, + medication.intakeRemindersEnabled ?? false + ); + + if (!personTakesMedication(share.takenBy, medTakenBy, intakes)) { + return false; + } + + const intake = intakes[parsedDose.intakeIndex]; + if (!intake) { + return false; + } + + const expectedPersons = intake.takenBy ? [intake.takenBy] : medTakenBy; + if (expectedPersons.length === 0) { + return parsedDose.personSuffix === null; + } + + if (!parsedDose.personSuffix) { + return true; + } + + return expectedPersons.includes(parsedDose.personSuffix); +} + // ============================================================================= // Dose Tracking Routes // ============================================================================= @@ -215,9 +308,9 @@ export async function doseRoutes(app: FastifyInstance) { app.get<{ Params: { token: string } }>("/share/:token/doses", async (request, reply) => { const { token } = request.params; - // Find share token - const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token)); + const { share, reason } = await getActiveShareToken(token); if (!share) { + request.log.warn(`[ShareDose] Rejected read for token ${maskToken(token)} (reason=${reason})`); return reply.notFound("Share link not found"); } @@ -252,12 +345,20 @@ export async function doseRoutes(app: FastifyInstance) { const { doseId } = parsed.data; - // Find share token - const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token)); + const { share, reason } = await getActiveShareToken(token); if (!share) { + request.log.warn(`[ShareDose] Rejected mark 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 mark request (owner=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId})` + ); + return reply.status(400).send({ error: "Invalid or unauthorized doseId" }); + } + // Check if already marked const [existing] = await db .select() @@ -265,6 +366,7 @@ export async function doseRoutes(app: FastifyInstance) { .where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId))); if (existing) { + request.log.debug(`[ShareDose] Duplicate mark ignored (owner=${share.userId}, doseId=${doseId})`); return { success: true, message: "Already marked" }; } @@ -276,6 +378,10 @@ export async function doseRoutes(app: FastifyInstance) { takenSource: "manual", }); + request.log.info( + `[ShareDose] Dose marked via share link (owner=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId})` + ); + return { success: true }; } ); @@ -286,12 +392,20 @@ export async function doseRoutes(app: FastifyInstance) { app.delete<{ Params: { token: string; doseId: string } }>("/share/:token/doses/:doseId", async (request, reply) => { const { token, doseId } = request.params; - // Find share token - const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token)); + 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() @@ -300,9 +414,13 @@ export async function doseRoutes(app: FastifyInstance) { 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/share.ts b/backend/src/routes/share.ts index 09f69ec..3489ed6 100644 --- a/backend/src/routes/share.ts +++ b/backend/src/routes/share.ts @@ -1,5 +1,5 @@ import { randomBytes } from "node:crypto"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import { db } from "../db/client.js"; @@ -14,9 +14,6 @@ import { 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 // ============================================================================= @@ -25,6 +22,11 @@ const createShareSchema = z.object({ scheduleDays: z.number().int().min(1).max(365).default(30), }); +function maskToken(token: string): string { + if (token.length <= 8) return token; + return `${token.slice(0, 4)}...${token.slice(-4)}`; +} + // Helper to get user ID from request // Returns anonymous user ID when auth is disabled async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise { @@ -54,6 +56,7 @@ export async function shareRoutes(app: FastifyInstance) { // 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: ${maskToken(token)}`); return reply.status(404).send({ error: "Share link not found", code: "NOT_FOUND", @@ -62,6 +65,9 @@ export async function shareRoutes(app: FastifyInstance) { // Check if token has expired if (share.expiresAt && share.expiresAt.getTime() < Date.now()) { + request.log.warn( + `[Share] Expired token requested: ${maskToken(token)} (owner=${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({ @@ -197,25 +203,47 @@ export async function shareRoutes(app: FastifyInstance) { }); } - // Generate unique token (8 bytes = 16 hex chars) + // 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 (owner=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays})` + ); + + return { + reused: true, + token: existingShare.token, + shareUrl: `/share/${existingShare.token}`, + expiresAt: null, + }; + } + 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, + userId, token, takenBy, scheduleDays, - expiresAt, + expiresAt: null, }); + request.log.info( + `[Share] Created new share token (owner=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays})` + ); + return { + reused: false, token, shareUrl: `/share/${token}`, - expiresAt: expiresAt.toISOString(), + expiresAt: null, }; } ); diff --git a/frontend/src/hooks/useShare.ts b/frontend/src/hooks/useShare.ts index 91df596..8308f0e 100644 --- a/frontend/src/hooks/useShare.ts +++ b/frontend/src/hooks/useShare.ts @@ -4,6 +4,8 @@ import { useCallback, useState } from "react"; import type { Medication } from "../types"; +import { withCorrelation } from "../utils/correlation"; +import { log } from "../utils/logger"; export interface UseShareReturn { showShareDialog: boolean; @@ -45,36 +47,57 @@ export function useShare(): UseShareReturn { const allPeople = meds.flatMap((m) => m.takenBy || []); const uniquePeople = [...new Set(allPeople)].filter(Boolean).sort(); setSharePeople(uniquePeople); + log.info("[ShareDialog] Opened", { medicationCount: meds.length, personCount: uniquePeople.length }); if (uniquePeople.length > 0) { setShareSelectedPerson(uniquePeople[0]); } }, []); const generateShareLink = useCallback(async () => { - if (!shareSelectedPerson) return; + if (!shareSelectedPerson) { + log.warn("[ShareDialog] Attempted to generate link without selected person"); + return; + } setShareGenerating(true); setShareCopied(false); try { - const res = await fetch("/api/share", { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify({ - takenBy: shareSelectedPerson, - scheduleDays: shareSelectedDays, - }), - }); + const { correlationId, init } = withCorrelation( + { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + takenBy: shareSelectedPerson, + scheduleDays: shareSelectedDays, + }), + }, + "fe-share" + ); + const res = await fetch("/api/share", init); if (res.ok) { const data = await res.json(); const fullUrl = `${window.location.origin}/share/${data.token}`; setShareLink(fullUrl); + log.info("[ShareDialog] Share link ready", { + person: shareSelectedPerson, + days: shareSelectedDays, + reused: Boolean(data.reused), + correlationId, + }); } else { const err = await res.json(); + log.error("[ShareDialog] Failed to generate share link", { + status: res.status, + person: shareSelectedPerson, + error: err.error, + correlationId, + }); alert(err.error || "Failed to generate share link"); } - } catch { + } catch (error) { + log.error("[ShareDialog] Share link request threw error", { person: shareSelectedPerson, error }); alert("Failed to generate share link"); } finally { setShareGenerating(false); @@ -85,18 +108,21 @@ export function useShare(): UseShareReturn { if (shareLink) { navigator.clipboard.writeText(shareLink); setShareCopied(true); + log.debug("[ShareDialog] Share link copied to clipboard"); setTimeout(() => setShareCopied(false), 2000); } }, [shareLink]); const closeShareDialog = useCallback(() => { if (showShareDialog) { + log.debug("[ShareDialog] Closing dialog"); window.history.back(); } }, [showShareDialog]); // Internal function to reset share dialog state (called by popstate handler) const resetShareDialogState = useCallback(() => { + log.debug("[ShareDialog] Reset dialog state"); setShowShareDialog(false); setShareLink(null); setShareCopied(false); diff --git a/frontend/src/utils/correlation.ts b/frontend/src/utils/correlation.ts new file mode 100644 index 0000000..6e387da --- /dev/null +++ b/frontend/src/utils/correlation.ts @@ -0,0 +1,18 @@ +function createCorrelationId(prefix = "fe"): string { + const randomPart = Math.random().toString(36).slice(2, 10); + return `${prefix}-${Date.now()}-${randomPart}`; +} + +export function withCorrelation(init: RequestInit, prefix = "fe"): { correlationId: string; init: RequestInit } { + const correlationId = createCorrelationId(prefix); + const headers = new Headers(init.headers ?? undefined); + headers.set("x-correlation-id", correlationId); + + return { + correlationId, + init: { + ...init, + headers, + }, + }; +}