Files
medassist-ng/backend/src/routes/share.ts
T
Daniel Volz f56f2b7c88 feat: backend improvements - reminder tracking, share stock status, planner notifications (#145)
- Separate stock/intake reminder tracking in DB with dedicated columns
- Add shareStockStatus setting to control stock visibility on shared links
- Rewrite planner notification to support both email and Shoutrrr push
- Add push notification footer text for intake and stock reminders
- New DB migrations: stock_reminder_tracking (0006), share_stock_status (0007)
- Update backend i18n with demandCalculator section and critically low text
- Add 514 passing backend tests including new coverage for all changes
2026-02-09 19:32:32 +01:00

258 lines
9.3 KiB
TypeScript

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<number> {
// 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<typeof createShareSchema> }>(
"/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<string>();
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() };
});
}