feat(share): implement share functionality for medication schedules with token-based access

This commit is contained in:
Daniel Volz
2025-12-26 21:06:03 +01:00
parent a7f9f90db4
commit b0f26b1e66
8 changed files with 887 additions and 16 deletions
+10
View File
@@ -83,6 +83,16 @@ async function main() {
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS share_tokens (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
token text NOT NULL UNIQUE,
taken_by text NOT NULL,
schedule_days integer NOT NULL DEFAULT 30,
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
`;
// Execute each statement separately
+12
View File
@@ -87,3 +87,15 @@ export const refreshTokens = sqliteTable("refresh_tokens", {
revoked: integer("revoked", { mode: "boolean" }).notNull().default(false),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
});
// =============================================================================
// Share Tokens - For public schedule sharing by takenBy person
// =============================================================================
export const shareTokens = sqliteTable("share_tokens", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
token: text("token", { length: 64 }).notNull().unique(),
takenBy: text("taken_by", { length: 100 }).notNull(),
scheduleDays: integer("schedule_days").notNull().default(30),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
});
+2
View File
@@ -16,6 +16,7 @@ import { authRoutes } from "./routes/auth.js";
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 { startReminderScheduler } from "./services/reminder-scheduler.js";
import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js";
@@ -99,6 +100,7 @@ await app.register(authRoutes);
await app.register(medicationRoutes);
await app.register(settingsRoutes);
await app.register(plannerRoutes);
await app.register(shareRoutes);
const start = async () => {
try {
+152
View File
@@ -0,0 +1,152 @@
import { FastifyInstance } from "fastify";
import { z } from "zod";
import { randomBytes } from "crypto";
import { db } from "../db/client.js";
import { medications, shareTokens } from "../db/schema.js";
import { eq, and } from "drizzle-orm";
import { requireAuth, optionalAuth } from "../plugins/auth.js";
import type { AuthUser } from "../types/fastify.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),
});
// =============================================================================
// 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.notFound("Share link not found");
}
// Get medications for this user filtered by takenBy
const meds = await db.select().from(medications).where(
and(
eq(medications.userId, share.userId),
eq(medications.takenBy, share.takenBy)
)
);
// Parse slices and build schedule data
const medicationsWithSlices = meds.map((med) => {
let slices: { usage: number; every: number; start: string }[] = [];
try {
const usageArr = JSON.parse(med.usageJson || "[]");
const everyArr = JSON.parse(med.everyJson || "[]");
const startArr = JSON.parse(med.startJson || "[]");
slices = usageArr.map((usage: number, i: number) => ({
usage,
every: everyArr[i] ?? 1,
start: startArr[i] ?? new Date().toISOString(),
}));
} catch {
slices = [];
}
return {
id: med.id,
name: med.name,
genericName: med.genericName,
pillWeightMg: med.pillWeightMg,
imageUrl: med.imageUrl,
slices,
};
});
return {
takenBy: share.takenBy,
scheduleDays: share.scheduleDays,
medications: medicationsWithSlices,
};
});
// ---------------------------------------------------------------------------
// POST /share - PROTECTED: Create a new share link
// ---------------------------------------------------------------------------
app.post<{ Body: z.infer<typeof createShareSchema> }>(
"/share",
{ 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 = 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
const [existingMed] = await db.select().from(medications).where(
and(
eq(medications.userId, authUser.id),
eq(medications.takenBy, takenBy)
)
);
if (!existingMed) {
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");
// Create share token
await db.insert(shareTokens).values({
userId: authUser.id,
token,
takenBy,
scheduleDays,
});
return {
token,
shareUrl: `/share/${token}`,
};
}
);
// ---------------------------------------------------------------------------
// GET /share/people - PROTECTED: Get list of unique takenBy values
// ---------------------------------------------------------------------------
app.get(
"/share/people",
{ 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 all unique takenBy values for this user
const meds = await db.select({ takenBy: medications.takenBy })
.from(medications)
.where(eq(medications.userId, authUser.id));
const uniquePeople = [...new Set(meds.map((m) => m.takenBy).filter(Boolean))] as string[];
return { people: uniquePeople };
}
);
}