feat(dose-tracking): implement dose tracking functionality with API routes for marking and unmarking doses

This commit is contained in:
Daniel Volz
2025-12-26 21:40:53 +01:00
parent b0f26b1e66
commit 473ffde4fe
6 changed files with 362 additions and 48 deletions
@@ -0,0 +1,11 @@
-- Dose tracking table for syncing taken doses between users and share links
CREATE TABLE IF NOT EXISTS dose_tracking (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
dose_id TEXT NOT NULL,
taken_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
marked_by TEXT
);
-- Index for fast lookups by user and dose
CREATE INDEX IF NOT EXISTS idx_dose_tracking_user_dose ON dose_tracking(user_id, dose_id);
+2 -1
View File
@@ -10,6 +10,7 @@
{ "idx": 7, "version": 1, "when": 1735300000, "tag": "0007_add_intake_reminders", "breakpoint": false },
{ "idx": 8, "version": 1, "when": 1735400000, "tag": "0008_add_pill_weight", "breakpoint": false },
{ "idx": 9, "version": 1, "when": 1735500000, "tag": "0009_add_taken_by", "breakpoint": false },
{ "idx": 10, "version": 1, "when": 1735600000, "tag": "0010_add_user_settings", "breakpoint": false }
{ "idx": 10, "version": 1, "when": 1735600000, "tag": "0010_add_user_settings", "breakpoint": false },
{ "idx": 11, "version": 1, "when": 1735700000, "tag": "0011_add_dose_tracking", "breakpoint": false }
]
}
+11
View File
@@ -99,3 +99,14 @@ export const shareTokens = sqliteTable("share_tokens", {
scheduleDays: integer("schedule_days").notNull().default(30),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
});
// =============================================================================
// Dose Tracking - Tracks when doses are marked as taken
// =============================================================================
export const doseTracking = sqliteTable("dose_tracking", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
doseId: text("dose_id", { length: 255 }).notNull(), // e.g. "med-5-1-86400000-1735200000000"
takenAt: integer("taken_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link
});
+2
View File
@@ -17,6 +17,7 @@ import { medicationRoutes } from "./routes/medications.js";
import { settingsRoutes } from "./routes/settings.js";
import { plannerRoutes } from "./routes/planner.js";
import { shareRoutes } from "./routes/share.js";
import { doseRoutes } from "./routes/doses.js";
import { startReminderScheduler } from "./services/reminder-scheduler.js";
import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js";
@@ -101,6 +102,7 @@ await app.register(medicationRoutes);
await app.register(settingsRoutes);
await app.register(plannerRoutes);
await app.register(shareRoutes);
await app.register(doseRoutes);
const start = async () => {
try {
+237
View File
@@ -0,0 +1,237 @@
import { FastifyInstance } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
import { doseTracking, shareTokens } from "../db/schema.js";
import { eq, and, gte } from "drizzle-orm";
import { requireAuth } from "../plugins/auth.js";
import type { AuthUser } from "../types/fastify.js";
// =============================================================================
// Validation Schemas
// =============================================================================
const markDoseSchema = z.object({
doseId: z.string().min(1, "doseId is required"),
});
const shareDoseSchema = z.object({
doseId: z.string().min(1, "doseId is required"),
});
// =============================================================================
// Dose Tracking Routes
// =============================================================================
export async function doseRoutes(app: FastifyInstance) {
// ---------------------------------------------------------------------------
// GET /doses/taken - PROTECTED: Get all taken doses for the user
// ---------------------------------------------------------------------------
app.get(
"/doses/taken",
{ preHandler: requireAuth },
async (request, reply) => {
const authUser = request.user as unknown as AuthUser | null;
if (!authUser) {
return reply.status(401).send({ error: "Not authenticated" });
}
// Get doses from last 30 days (to avoid loading too much data)
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const doses = await db.select()
.from(doseTracking)
.where(
and(
eq(doseTracking.userId, authUser.id),
gte(doseTracking.takenAt, thirtyDaysAgo)
)
);
return {
doses: doses.map((d) => ({
doseId: d.doseId,
takenAt: d.takenAt?.getTime() ?? Date.now(),
markedBy: d.markedBy,
})),
};
}
);
// ---------------------------------------------------------------------------
// POST /doses/taken - PROTECTED: Mark a dose as taken
// ---------------------------------------------------------------------------
app.post<{ Body: z.infer<typeof markDoseSchema> }>(
"/doses/taken",
{ preHandler: requireAuth },
async (request, reply) => {
const authUser = request.user as unknown as AuthUser | null;
if (!authUser) {
return reply.status(401).send({ error: "Not authenticated" });
}
const parsed = markDoseSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
error: parsed.error.errors[0]?.message ?? "Invalid input",
});
}
const { doseId } = parsed.data;
// Check if already marked
const [existing] = await db.select()
.from(doseTracking)
.where(
and(
eq(doseTracking.userId, authUser.id),
eq(doseTracking.doseId, doseId)
)
);
if (existing) {
return { success: true, message: "Already marked" };
}
// Insert new record
await db.insert(doseTracking).values({
userId: authUser.id,
doseId,
markedBy: null, // Marked by the user themselves
});
return { success: true };
}
);
// ---------------------------------------------------------------------------
// DELETE /doses/taken/:doseId - PROTECTED: Unmark a dose
// ---------------------------------------------------------------------------
app.delete<{ Params: { doseId: string } }>(
"/doses/taken/:doseId",
{ preHandler: requireAuth },
async (request, reply) => {
const authUser = request.user as unknown as AuthUser | null;
if (!authUser) {
return reply.status(401).send({ error: "Not authenticated" });
}
const { doseId } = request.params;
await db.delete(doseTracking).where(
and(
eq(doseTracking.userId, authUser.id),
eq(doseTracking.doseId, doseId)
)
);
return { success: true };
}
);
// ---------------------------------------------------------------------------
// GET /share/:token/doses - PUBLIC: Get taken doses for a share link
// ---------------------------------------------------------------------------
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));
if (!share) {
return reply.notFound("Share link not found");
}
// Get doses from last 30 days
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const doses = await db.select()
.from(doseTracking)
.where(
and(
eq(doseTracking.userId, share.userId),
gte(doseTracking.takenAt, thirtyDaysAgo)
)
);
return {
doses: doses.map((d) => ({
doseId: d.doseId,
takenAt: d.takenAt?.getTime() ?? Date.now(),
markedBy: d.markedBy,
})),
};
}
);
// ---------------------------------------------------------------------------
// POST /share/:token/doses - PUBLIC: Mark a dose as taken via share link
// ---------------------------------------------------------------------------
app.post<{ Params: { token: string }; Body: z.infer<typeof shareDoseSchema> }>(
"/share/:token/doses",
async (request, reply) => {
const { token } = request.params;
const parsed = shareDoseSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
error: parsed.error.errors[0]?.message ?? "Invalid input",
});
}
const { doseId } = parsed.data;
// Find share token
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
if (!share) {
return reply.notFound("Share link not found");
}
// Check if already marked
const [existing] = await db.select()
.from(doseTracking)
.where(
and(
eq(doseTracking.userId, share.userId),
eq(doseTracking.doseId, doseId)
)
);
if (existing) {
return { success: true, message: "Already marked" };
}
// Insert new record - marked by the takenBy person
await db.insert(doseTracking).values({
userId: share.userId,
doseId,
markedBy: share.takenBy, // e.g. "Daniel"
});
return { success: true };
}
);
// ---------------------------------------------------------------------------
// DELETE /share/:token/doses/:doseId - PUBLIC: Unmark a dose via share link
// ---------------------------------------------------------------------------
app.delete<{ Params: { token: string; doseId: string } }>(
"/share/:token/doses/:doseId",
async (request, reply) => {
const { token, doseId } = request.params;
// Find share token
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
if (!share) {
return reply.notFound("Share link not found");
}
await db.delete(doseTracking).where(
and(
eq(doseTracking.userId, share.userId),
eq(doseTracking.doseId, doseId)
)
);
return { success: true };
}
);
}