f56f2b7c88
- 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
258 lines
9.3 KiB
TypeScript
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() };
|
|
});
|
|
}
|